diff --git a/lib/common/defaults.js b/lib/common/defaults.js index a4fb339..b0e299a 100644 --- a/lib/common/defaults.js +++ b/lib/common/defaults.js @@ -88,4 +88,6 @@ Defaults.BLOCKHEIGHT_CACHE_TIME = 10 * 60; Defaults.MAX_NOTIFICATIONS_TIMESPAN = 60 * 60 * 24 * 14; // ~ 2 weeks Defaults.NOTIFICATIONS_TIMESPAN = 60; +Defaults.SESSION_EXPIRATION = 1 * 60 * 60; // 1 hour to session expiration + module.exports = Defaults; diff --git a/lib/server.js b/lib/server.js index 9c19990..d08dc25 100644 --- a/lib/server.js +++ b/lib/server.js @@ -196,32 +196,65 @@ WalletService.getInstance = function(opts) { * Gets an instance of the server after authenticating the copayer. * @param {Object} opts * @param {string} opts.copayerId - The copayer id making the request. - * @param {string} opts.message - The contents of the request to be signed. - * @param {string} opts.signature - Signature of message to be verified using one of the copayer's requestPubKeys + * @param {string} opts.message - (Optional) The contents of the request to be signed. Only needed if no session token is provided. + * @param {string} opts.signature - (Optional) Signature of message to be verified using one of the copayer's requestPubKeys. Only needed if no session token is provided. + * @param {string} opts.session - (Optional) A valid session token previously obtained using the #login method * @param {string} opts.clientVersion - A string that identifies the client issuing the request */ WalletService.getInstanceWithAuth = function(opts, cb) { - if (!checkRequired(opts, ['copayerId', 'message', 'signature'], cb)) return; + function withSignature(cb) { + if (!checkRequired(opts, ['copayerId', 'message', 'signature'], cb)) return; - var server; - try { - server = WalletService.getInstance(opts); - } catch (ex) { - return cb(ex); - } + var server; + try { + server = WalletService.getInstance(opts); + } catch (ex) { + return cb(ex); + } - server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { - if (err) return cb(err); - if (!copayer) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found')); + server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { + if (err) return cb(err); + if (!copayer) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found')); - var isValid = !!server._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys); - if (!isValid) - return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Invalid signature')); + var isValid = !!server._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys); + if (!isValid) + return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Invalid signature')); - server.copayerId = opts.copayerId; - server.walletId = copayer.walletId; - return cb(null, server); - }); + server.copayerId = opts.copayerId; + server.walletId = copayer.walletId; + return cb(null, server); + }); + }; + + function withSession(cb) { + if (!checkRequired(opts, ['copayerId', 'session'], cb)) return; + + var server; + try { + server = WalletService.getInstance(opts); + } catch (ex) { + return cb(ex); + } + + server.storage.getSession(opts.copayerId, function(err, s) { + if (err) return cb(err); + + var isValid = s && s.id == opts.session && s.isValid(); + if (!isValid) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Session expired')); + + server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { + if (err) return cb(err); + if (!copayer) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found')); + + server.copayerId = opts.copayerId; + server.walletId = copayer.walletId; + return cb(null, server); + }); + }); + }; + + var authFn = opts.session ? withSession : withSignature; + return authFn(cb); }; WalletService.prototype._runLocked = function(cb, task) { @@ -229,6 +262,46 @@ WalletService.prototype._runLocked = function(cb, task) { this.lock.runLocked(this.walletId, cb, task); }; +WalletService.prototype.login = function(opts, cb) { + var self = this; + + var session; + async.series([ + + function(next) { + self.storage.getSession(self.copayerId, function(err, s) { + if (err) return next(err); + session = s; + next(); + }); + }, + function(next) { + if (!session || !session.isValid()) { + session = Model.Session.create({ + copayerId: self.copayerId, + walletId: self.walletId, + }); + } else { + session.touch(); + } + next(); + }, + function(next) { + self.storage.storeSession(session, next); + }, + ], function(err) { + if (err) return cb(err); + if (!session) return cb(new Error('Could not get current session for this copayer')); + + return cb(null, session.id); + }); +}; + +WalletService.prototype.logout = function(opts, cb) { + var self = this; + + self.storage.removeSession(self.copayerId, cb); +}; /** * Creates a new wallet. diff --git a/lib/storage.js b/lib/storage.js index e6a43f8..f63f968 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -23,6 +23,7 @@ var collections = { CACHE: 'cache', FIAT_RATES: 'fiat_rates', TX_NOTES: 'tx_notes', + SESSIONS: 'sessions', }; var Storage = function(opts) { @@ -868,6 +869,27 @@ Storage.prototype.storeTxNote = function(txNote, cb) { }, cb); }; +Storage.prototype.getSession = function(copayerId, cb) { + var self = this; + + self.db.collection(collections.SESSIONS).findOne({ + copayerId: copayerId, + }, + function(err, result) { + if (err || !result) return cb(err); + return cb(null, Model.Session.fromObj(result)); + }); +}; + +Storage.prototype.storeSession = function(session, cb) { + this.db.collection(collections.SESSIONS).update({ + copayerId: session.copayerId, + }, session.toObject(), { + w: 1, + upsert: true, + }, cb); +}; + Storage.prototype._dump = function(cb, fn) { fn = fn || console.log; cb = cb || function() {}; diff --git a/test/integration/server.js b/test/integration/server.js index 71bd247..5369da4 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -142,6 +142,105 @@ describe('Wallet service', function() { }); }); + describe('Session management (#login, #logout, #authenticate)', function() { + var server, wallet; + beforeEach(function(done) { + helpers.createAndJoinWallet(1, 2, function(s, w) { + server = s; + wallet = w; + done(); + }); + }); + + it('should get a new session & authenticate', function(done) { + WalletService.getInstanceWithAuth({ + copayerId: server.copayerId, + session: 'dummy', + }, function(err, server2) { + should.exist(err); + err.code.should.equal('NOT_AUTHORIZED'); + err.message.toLowerCase().should.contain('session'); + should.not.exist(server2); + server.login({}, function(err, token) { + should.not.exist(err); + should.exist(token); + WalletService.getInstanceWithAuth({ + copayerId: server.copayerId, + session: token, + }, function(err, server2) { + should.not.exist(err); + should.exist(server2); + server2.copayerId.should.equal(server.copayerId); + server2.walletId.should.equal(server.walletId); + done(); + }); + }); + }); + }); + it('should get the same session token for two requests in a row', function(done) { + server.login({}, function(err, token) { + should.not.exist(err); + should.exist(token); + server.login({}, function(err, token2) { + should.not.exist(err); + token2.should.equal(token); + done(); + }); + }); + }); + it('should create a new session if the previous one has expired', function(done) { + var timer = sinon.useFakeTimers('Date'); + var token; + async.series([ + + function(next) { + server.login({}, function(err, t) { + should.not.exist(err); + should.exist(t); + token = t; + next(); + }); + }, + function(next) { + WalletService.getInstanceWithAuth({ + copayerId: server.copayerId, + session: token, + }, function(err, server2) { + should.not.exist(err); + should.exist(server2); + next(); + }); + }, + function(next) { + timer.tick((Defaults.SESSION_EXPIRATION + 1) * 1000); + next(); + }, + function(next) { + server.login({}, function(err, t) { + should.not.exist(err); + t.should.not.equal(token); + next(); + }); + }, + function(next) { + WalletService.getInstanceWithAuth({ + copayerId: server.copayerId, + session: token, + }, function(err, server2) { + should.exist(err); + err.code.should.equal('NOT_AUTHORIZED'); + err.message.should.contain('expired'); + next(); + }); + }, + ], function(err) { + should.not.exist(err); + timer.restore(); + done(); + }); + }); + }); + describe('#createWallet', function() { var server; beforeEach(function() {