├── .gitignore ├── .travis.yml ├── package.json ├── LICENSE ├── test └── mongodb.js ├── README.md └── lib └── mongodb.js /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store* 3 | *.log 4 | *.gz 5 | 6 | node_modules 7 | coverage 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | node_js: 2 | - "iojs" 3 | sudo: false 4 | language: node_js 5 | script: "npm run-script test-travis" 6 | after_script: "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls" 7 | services: 8 | - mongodb 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-atomic-session", 3 | "description": "Atomic sessions for Koa", 4 | "version": "1.0.0", 5 | "author": "Jonathan Ong (http://jongleberry.com)", 6 | "license": "MIT", 7 | "repository": "koajs/atomic-session", 8 | "dependencies": { 9 | "csrf": "^2.0.2", 10 | "mongodb": "^2.0.25", 11 | "mongodb-next": "^0.7.0", 12 | "ms": "^2.0.0" 13 | }, 14 | "devDependencies": { 15 | "istanbul": "0", 16 | "koa": "0", 17 | "mocha": "2", 18 | "standardberry": "*", 19 | "supertest": "0" 20 | }, 21 | "scripts": { 22 | "lint": "standardberry lib/*.js", 23 | "test": "mocha", 24 | "test-cov": "istanbul cover ./node_modules/.bin/_mocha -- --reporter dot", 25 | "test-travis": "npm run lint && istanbul cover ./node_modules/.bin/_mocha --report lcovonly -- --reporter dot" 26 | }, 27 | "keywords": [ 28 | "session", 29 | "mongodb", 30 | "koa", 31 | "atomic" 32 | ], 33 | "files": [ 34 | "lib" 35 | ], 36 | "main": "lib/mongodb" 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014 Jonathan Ong me@jongleberry.com 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/mongodb.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const request = require('supertest') 4 | const assert = require('assert') 5 | const koa = require('koa') 6 | 7 | const session = require('..') 8 | 9 | let collection 10 | 11 | before(function (done) { 12 | require('mongodb').MongoClient.connect('mongodb://localhost/koa-atomic-session', function (err, db) { 13 | if (err) throw err 14 | collection = db.collection('sessions') 15 | done() 16 | }) 17 | }) 18 | 19 | describe('Mongodb Atomic Session', function () { 20 | describe('when not accessed', function () { 21 | it('should not set cookies', function (done) { 22 | let app = App() 23 | 24 | request(app.listen()) 25 | .get('/') 26 | .expect(404) 27 | .end(function (err, res) { 28 | assert.ifError(err) 29 | assert(!res.headers['set-cookie']) 30 | done() 31 | }) 32 | }) 33 | }) 34 | 35 | describe('when accessed', function () { 36 | it('should set a cookie', function (done) { 37 | let app = App() 38 | 39 | app.use(function* () { 40 | let session = yield this.session() 41 | yield session.set('message', 'hello').then(session.update) 42 | assert.equal(session.message, 'hello') 43 | assert(session.maxAge) 44 | assert(session.expires) 45 | assert(session.id) 46 | this.status = 204 47 | }) 48 | 49 | request(app.listen()) 50 | .get('/') 51 | .expect('Set-Cookie', /[0-9a-f]{24}/i) 52 | .expect(204, done) 53 | }) 54 | }) 55 | 56 | describe('MongoDB Commands', function () { 57 | it('should unset stuff', function (done) { 58 | let app = App() 59 | 60 | app.use(function* () { 61 | let session = yield this.session() 62 | yield session.set('message', 'hello').then(session.update) 63 | assert.equal(session.message, 'hello') 64 | yield session.unset('message').then(session.update) 65 | assert(!('message' in session)) 66 | assert(session.maxAge) 67 | assert(session.expires) 68 | assert(session.id) 69 | this.status = 204 70 | }) 71 | 72 | request(app.listen()) 73 | .get('/') 74 | .expect('Set-Cookie', /[0-9a-f]{24}/i) 75 | .expect(204, done) 76 | }) 77 | }) 78 | 79 | it('should regenerate sessions', function (done) { 80 | let app = App() 81 | 82 | app.use(function* () { 83 | let session = yield this.session() 84 | yield session.set('message', 'hello') 85 | let session2 = yield session.regenerate() 86 | assert(!session._id.equals(session2._id)) 87 | assert(!session.message) 88 | this.status = 204 89 | }) 90 | 91 | request(app.listen()) 92 | .get('/') 93 | .expect('Set-Cookie', /[0-9a-f]{24}/i) 94 | .expect(204, done) 95 | }) 96 | 97 | it('should support CSRF tokens', function (done) { 98 | let app = App() 99 | 100 | app.use(function* () { 101 | let session = yield this.session() 102 | let csrf = session.createCSRF() 103 | session.assertCSRF(csrf) 104 | this.status = 204 105 | }) 106 | 107 | request(app.listen()) 108 | .get('/') 109 | .expect('Set-Cookie', /[0-9a-f]{24}/i) 110 | .expect(204, done) 111 | }) 112 | 113 | it('should grab sessions from the cookie', function (done) { 114 | let app = App() 115 | 116 | app.use(function* (next) { 117 | if (this.method !== 'POST') return yield next 118 | let session = yield this.session() 119 | yield session.set('message', 'hello') 120 | this.status = 204 121 | }) 122 | 123 | app.use(function* (next) { 124 | let session = yield this.session() 125 | assert.equal(session.message, 'hello') 126 | assert(session.maxAge) 127 | assert(session.expires) 128 | assert(session.secret) 129 | this.status = 204 130 | }) 131 | 132 | let server = app.listen() 133 | 134 | request(server) 135 | .post('/') 136 | .expect('Set-Cookie', /[0-9a-f]{24}/i) 137 | .expect(204, function (err, res) { 138 | assert.ifError(err) 139 | 140 | let cookie = res.headers['set-cookie'].join(';') 141 | 142 | request(server) 143 | .get('/') 144 | .set('cookie', cookie) 145 | .expect(204, done) 146 | }) 147 | }) 148 | }) 149 | 150 | function App(options) { 151 | let app = koa() 152 | app.keys = ['a', 'b'] 153 | let Session = session(app, options) 154 | Session.collection = collection 155 | Session.ensureIndex() 156 | return app 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # atomic-session 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![Build status][travis-image]][travis-url] 6 | [![Test coverage][coveralls-image]][coveralls-url] 7 | [![Dependency Status][david-image]][david-url] 8 | [![License][license-image]][license-url] 9 | [![Downloads][downloads-image]][downloads-url] 10 | [![Gittip][gittip-image]][gittip-url] 11 | 12 | Atomic sessions for Koa. 13 | 14 | - Currently uses MongoDB. 15 | - Atomic updates - don't butcher the entire session. 16 | - Don't grab the session from the database unless necessary. 17 | - Better error handling. 18 | - Includes CSRF token handling 19 | 20 | ## Usage 21 | 22 | ```js 23 | // create the app 24 | var app = koa() 25 | 26 | // attach the session to the app 27 | var MongoDBSession = require('koa-atomic-session')(app, { 28 | maxAge: '1 month' 29 | }) 30 | 31 | // asynchronously attach the collection 32 | // you should not start the app until you do this 33 | require('mongodb').MongoClient.connect('mongodb://localhost', function (err, db) { 34 | if (err) throw err 35 | // set the collection 36 | MongoDBSession.collection = db.collection('sessions') 37 | // ensure indexes every time! 38 | MongoDBSession.ensureIndex() 39 | }) 40 | 41 | // use it in your app 42 | app.use(function* (next) { 43 | var session = yield this.session() 44 | 45 | yield session.unset('user_id') 46 | yield session.set('user_id', new ObjectID()).then(session.update) 47 | }) 48 | ``` 49 | 50 | ## API 51 | 52 | ### var Session = Session(app, [options]) 53 | 54 | Options: 55 | 56 | - `key` - cookie key 57 | - `maxAge` - default to 14 days 58 | 59 | ### this.session().then( session => ) 60 | 61 | Grab the session from the database asynchronously. 62 | 63 | ### session.touch().then( session => ) 64 | 65 | Updates the new `expires` time. 66 | 67 | ### session\[command\](arguments...).then( => ) 68 | 69 | Change properties of the session. 70 | See database-specific options below. 71 | 72 | ### session.update().then( => ) 73 | 74 | Updates all the properties of the `session` object after running a command. 75 | Should always be added to a `.then()`. 76 | 77 | ```js 78 | yield session.set('message', 'hello') 79 | .then(session.update) 80 | assert.equal(session.message, 'hello') 81 | ``` 82 | 83 | ### session.destroy.then( => ) 84 | 85 | Destroys the session without creating a new one. 86 | 87 | ### session.regenerate.then( session => ) 88 | 89 | Creates a brand new session. 90 | 91 | ### var csrf = session.createCSRF() 92 | 93 | Create a CSRF token. 94 | 95 | ### session.assertCSRF(csrf) 96 | 97 | Assert that a CSRF token is valid. 98 | 99 | ## MongoDB API 100 | 101 | ### MongoDBSession.ensureIndex().then( => ) 102 | 103 | Adds indexes on the `expires` property so that expires are automatically set. 104 | 105 | ### MongoDBSession.collection = 106 | 107 | Set the collection asynchronously. 108 | You should set this collection before starting your app. 109 | 110 | ### session\[command\](arguments...).then( => ) 111 | 112 | Supports most MongoDB properties. 113 | This uses [mongodb-next](https://www.npmjs.com/package/mongodb-next) internally. 114 | Some commands that are supported are: 115 | 116 | - `.set(key, value)`` 117 | - `.unset(key)` 118 | - `.rename(name, newName)` 119 | - `.pull()` 120 | - `.addToSet()` 121 | 122 | [gitter-image]: https://badges.gitter.im/koajs/atomic-session.png 123 | [gitter-url]: https://gitter.im/koajs/atomic-session 124 | [npm-image]: https://img.shields.io/npm/v/koa-atomic-session.svg?style=flat-square 125 | [npm-url]: https://npmjs.org/package/koa-atomic-session 126 | [github-tag]: http://img.shields.io/github/tag/koajs/atomic-session.svg?style=flat-square 127 | [github-url]: https://github.com/koajs/atomic-session/tags 128 | [travis-image]: https://img.shields.io/travis/koajs/atomic-session.svg?style=flat-square 129 | [travis-url]: https://travis-ci.org/koajs/atomic-session 130 | [coveralls-image]: https://img.shields.io/coveralls/koajs/atomic-session.svg?style=flat-square 131 | [coveralls-url]: https://coveralls.io/r/koajs/atomic-session 132 | [david-image]: http://img.shields.io/david/koajs/atomic-session.svg?style=flat-square 133 | [david-url]: https://david-dm.org/koajs/atomic-session 134 | [license-image]: http://img.shields.io/npm/l/koa-atomic-session.svg?style=flat-square 135 | [license-url]: LICENSE 136 | [downloads-image]: http://img.shields.io/npm/dm/koa-atomic-session.svg?style=flat-square 137 | [downloads-url]: https://npmjs.org/package/koa-atomic-session 138 | [gittip-image]: https://img.shields.io/gratipay/jonathanong.svg?style=flat-square 139 | [gittip-url]: https://gratipay.com/jonathanong/ 140 | -------------------------------------------------------------------------------- /lib/mongodb.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const wrap = require('mongodb-next').collection 4 | const ObjectID = require('mongodb').ObjectID 5 | const assert = require('assert') 6 | const CSRF = require('csrf') 7 | const ms = require('ms') 8 | 9 | module.exports = function (app, options) { 10 | assert(app.middleware, 'First argument must be the app.') 11 | 12 | options = options || {} 13 | 14 | // CSRF options 15 | const csrf = CSRF(options) 16 | 17 | // the cookie name 18 | const key = options.key || 'sid' 19 | 20 | /** 21 | * Wrap the collection in a `mongodb-next` instance. 22 | */ 23 | 24 | Object.defineProperty(Session, 'collection', { 25 | get: function () { 26 | assert(this._collection, 'Collection not set!') 27 | return this._collection 28 | }, 29 | set: function (collection) { 30 | this._rawCollection = collection 31 | this._collection = wrap(collection) 32 | }, 33 | }) 34 | // set the collection if already defined 35 | if (options.collection) Session.collection = options.collection 36 | 37 | /** 38 | * Create a TTL index for expirations. 39 | */ 40 | 41 | Session.ensureIndex = function () { 42 | return Session.collection.ensureIndex({ 43 | expires: 1, 44 | }, { 45 | expireAfterSeconds: 0, 46 | background: true, 47 | }) 48 | } 49 | 50 | /** 51 | * Usage: 52 | * 53 | * const session = yield this.session() 54 | */ 55 | 56 | app.context.session = function () { 57 | // session is current being queried 58 | if (this._sessionPromise) return this._sessionPromise 59 | // session already queried 60 | if (this._session) return Promise.resolve(this._session) 61 | // get the session 62 | const self = this 63 | return this._sessionPromise = Promise.resolve().then(function () { 64 | const session = self._session = new Session(self) 65 | const sid = session.cookies.get(key, session) 66 | if (!/^[0-9a-f]{24}$/i.test(sid)) return session._create() 67 | 68 | return Session.collection.findOne(new ObjectID(sid)) 69 | .then(function (obj) { 70 | // non-existent session 71 | if (!obj) return session._create() 72 | 73 | // set all the properties locally 74 | Object.keys(obj).forEach(function (key) { 75 | session[key] = obj[key] 76 | }) 77 | 78 | return session 79 | }) 80 | }).then(function (session) { 81 | delete self._sessionPromise 82 | return session 83 | }) 84 | } 85 | 86 | /** 87 | * Session constructor. 88 | */ 89 | 90 | function Session(context) { 91 | this.context = context 92 | this.cookies = context.cookies 93 | this.update = this.update.bind(this) 94 | } 95 | 96 | /** 97 | * Get and set the `maxAge`. 98 | */ 99 | 100 | Object.defineProperty(Session.prototype, 'maxAge', { 101 | get: function () { 102 | return this._maxAge 103 | }, 104 | set: function (maxAge) { 105 | if (typeof maxAge === 'string') maxAge = ms(maxAge) 106 | assert(typeof maxAge === 'number') 107 | this._maxAge = maxAge 108 | }, 109 | }) 110 | 111 | /** 112 | * Cookie options. 113 | */ 114 | 115 | Session.prototype.overwrite = true 116 | Session.prototype.httpOnly = true 117 | Session.prototype.signed = true // should be encrypted later 118 | Session.prototype.maxAge = options.maxage || options.maxAge || '14 days' 119 | 120 | /** 121 | * .id is just a shorthand for ._id 122 | */ 123 | 124 | Object.defineProperty(Session.prototype, 'id', { 125 | get: function () { 126 | return this._id 127 | }, 128 | }) 129 | 130 | /** 131 | * Destroy the current session. 132 | * 133 | * const session = yield this.session() 134 | * yield session.destroy() 135 | * 136 | */ 137 | 138 | Session.prototype.destroy = function () { 139 | this.cookies.set(key, '', this) 140 | delete this.context._session 141 | return Promise.resolve(this._id && Session.collection.findOne(this._id).remove()) 142 | .then(noop) 143 | } 144 | 145 | /** 146 | * Destroy the current session and create a new one. 147 | * 148 | * const session = yield this.session() 149 | * session = yield session.regenerate() 150 | * 151 | */ 152 | 153 | Session.prototype.regenerate = function () { 154 | const context = this.context 155 | return this.destroy().then(function () { 156 | const session = context._session = new Session(context) 157 | return session._create() 158 | }) 159 | } 160 | 161 | /** 162 | * Update the expires age for the cookie as well as the session. 163 | * 164 | * const session = yield this.session() 165 | * yield session.touch() 166 | * 167 | */ 168 | 169 | Session.prototype.touch = function () { 170 | this._setSession() 171 | return Session.collection.findOne(this._id) 172 | .set('maxAge', this.maxAge) 173 | .set('expires', this.expires) 174 | .new() 175 | } 176 | 177 | /** 178 | * Create a CSRF token. 179 | * 180 | * const session = yield this.session() 181 | * const csrf = session.createCSRF() 182 | * 183 | */ 184 | 185 | Session.prototype.createCSRF = function () { 186 | assert(this.secret) 187 | return csrf.create(this.secret) 188 | } 189 | 190 | /** 191 | * Check whether a CSRF token is valid. 192 | * 193 | * const session = yield this.session() 194 | * session.assertCSRF(this.request.get('X-CSRF-Token')) 195 | * 196 | */ 197 | 198 | Session.prototype.assertCSRF = function (val) { 199 | this.context.assert(csrf.verify(this.secret, val), 401, 'Invalid CSRF Token.') 200 | } 201 | 202 | /** 203 | * Command entry points. 204 | * 205 | * const session = yield this.session() 206 | * yield session.set('a', 'b').unset('c').push('d', 1) 207 | */ 208 | 209 | const commands = [ 210 | 'addToSet', 211 | 'pop', 212 | 'pullAll', 213 | 'pull', 214 | 'pushAll', 215 | 'push', 216 | 'set', 217 | 'inc', 218 | 'unset', 219 | 'rename', 220 | ] 221 | commands.forEach(function (command) { 222 | Session.prototype[command] = function () { 223 | const query = this.touch() 224 | return query[command].apply(query, arguments) 225 | } 226 | }) 227 | 228 | /** 229 | * Update the session object with the results from an update. 230 | * Ideally, this would be automatic, but I haven't figured that out yet. 231 | * 232 | * const session = yield this.session() 233 | * yield session.set('a', 'b').then(session.update) 234 | * assert(session.a === 'b') 235 | * 236 | */ 237 | 238 | Session.prototype.update = function (session) { 239 | if (!session) return this 240 | 241 | const keys = Object.keys(this).filter(function (key) { 242 | // we have to manually ignore some keys 243 | switch (key) { 244 | case 'id': 245 | case 'context': 246 | case 'cookies': 247 | case '_maxAge': 248 | case 'update': 249 | return false 250 | } 251 | return true 252 | }) 253 | 254 | const newkeys = Object.keys(session) 255 | newkeys.forEach(function (key) { 256 | this[key] = session[key] 257 | const i = keys.indexOf(key) 258 | if (~i) keys.splice(i, 1) 259 | }, this) 260 | 261 | // removed shift 262 | keys.forEach(function (key) { 263 | delete this[key] 264 | }, this) 265 | 266 | return this 267 | } 268 | 269 | /** 270 | * Set the cookie expires as well as update this.expires. 271 | * You are expected to update .expires on the session as well. 272 | */ 273 | 274 | Session.prototype._setSession = function (expires) { 275 | this.expires = expires || new Date(Date.now() + this.maxAge) 276 | this.cookies.set(key, this._id.toHexString(), this) 277 | } 278 | 279 | /** 280 | * Create a new session. 281 | */ 282 | 283 | Session.prototype._create = function () { 284 | const self = this 285 | const session = { 286 | maxAge: this.maxAge, 287 | } 288 | this._id = session._id = new ObjectID() 289 | this.expires = session.expires = new Date(Date.now() + this.maxAge) 290 | this.created = session.created = new Date() 291 | this.cookies.set(key, this._id.toHexString(), this) 292 | return csrf.secret().then(function (secret) { 293 | self.secret = session.secret = secret 294 | return Session.collection.insert(session) 295 | }).then(function () { 296 | return self 297 | }) 298 | } 299 | 300 | return Session 301 | } 302 | 303 | /* istanbul ignore next */ 304 | function noop() {} 305 | --------------------------------------------------------------------------------