四好公路
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

461 lines
12 KiB

/**!
* koa-generic-session - lib/session.js
* Copyright(c) 2013 - 2014
* MIT Licensed
*
* Authors:
* dead_horse <dead_horse@qq.com> (http://deadhorse.me)
*/
'use strict';
/**
* Module dependencies.
*/
const debug = require('debug')('koa-generic-session:session');
const MemoryStore = require('./memory_store');
const crc32 = require('crc').crc32;
const parse = require('parseurl');
const Store = require('./store');
const copy = require('copy-to');
const uid = require('uid-safe');
/**
* Warning message for `MemoryStore` usage in production.
*/
const warning = 'Warning: koa-generic-session\'s MemoryStore is not\n' +
'designed for a production environment, as it will leak\n' +
'memory, and will not scale past a single process.';
const defaultCookie = {
httpOnly: true,
path: '/',
overwrite: true,
signed: true,
maxAge: 24 * 60 * 60 * 1000 //one day in ms
};
/**
* setup session store with the given `options`
* @param {Object} options
* - [`key`] cookie name, defaulting to `koa.sid`
* - [`store`] session store instance, default to MemoryStore
* - [`ttl`] store ttl in `ms`, default to oneday
* - [`prefix`] session prefix for store, defaulting to `koa:sess:`
* - [`cookie`] session cookie settings, defaulting to
* {path: '/', httpOnly: true, maxAge: null, rewrite: true, signed: true}
* - [`defer`] defer get session,
* - [`rolling`] rolling session, always reset the cookie and sessions, default is false
* you should `yield this.session` to get the session if defer is true, default is false
* - [`genSid`] you can use your own generator for sid
* - [`errorHandler`] handler for session store get or set error
* - [`valid`] valid(ctx, session), valid session value before use it
* - [`beforeSave`] beforeSave(ctx, session), hook before save session
* - [`sessionIdStore`] object with get, set, reset methods for passing session id throw requests.
*/
module.exports = function (options) {
options = options || {};
let key = options.key || 'koa.sid';
let client = options.store || new MemoryStore();
let errorHandler = options.errorHandler || defaultErrorHanlder;
let reconnectTimeout = options.reconnectTimeout || 10000;
let store = new Store(client, {
ttl: options.ttl,
prefix: options.prefix
});
let genSid = options.genSid || uid.sync;
let valid = options.valid || noop;
let beforeSave = options.beforeSave || noop;
let cookie = options.cookie || {};
copy(defaultCookie).to(cookie);
let storeStatus = 'available';
let waitStore = Promise.resolve();
// notify user that this store is not
// meant for a production environment
if ('production' === process.env.NODE_ENV
&& client instanceof MemoryStore) console.warn(warning);
let sessionIdStore = options.sessionIdStore || {
get: function() {
return this.cookies.get(key, cookie);
},
set: function(sid, session) {
this.cookies.set(key, sid, session.cookie);
},
reset: function() {
this.cookies.set(key, null);
}
};
store.on('disconnect', function() {
if (storeStatus !== 'available') return;
storeStatus = 'pending';
waitStore = new Promise(function (resolve, reject) {
setTimeout(function () {
if (storeStatus === 'pending') storeStatus = 'unavailable';
reject(new Error('session store is unavailable'));
}, reconnectTimeout);
store.once('connect', resolve);
});
});
store.on('connect', function() {
storeStatus = 'available';
waitStore = Promise.resolve();
});
return options.defer ? deferSession : session;
function addCommonAPI() {
this._sessionSave = null;
// more flexible
this.__defineGetter__('sessionSave', function () {
return this._sessionSave;
});
this.__defineSetter__('sessionSave', function (save) {
this._sessionSave = save;
});
}
/**
* generate a new session
*/
function generateSession() {
let session = {};
//you can alter the cookie options in nexts
session.cookie = {};
for (let prop in cookie) {
session.cookie[prop] = cookie[prop];
}
compatMaxage(session.cookie);
return session;
}
/**
* check url match cookie's path
*/
function matchPath(ctx) {
let pathname = parse(ctx).pathname;
let cookiePath = cookie.path || '/';
if (cookiePath === '/') {
return true;
}
if (pathname.indexOf(cookiePath) !== 0) {
debug('cookie path not match');
return false;
}
return true;
}
/**
* get session from store
* get sessionId from cookie
* save sessionId into context
* get session from store
*/
function *getSession() {
if (!matchPath(this)) return;
if (storeStatus === 'pending') {
debug('store is disconnect and pending');
yield waitStore;
} else if (storeStatus === 'unavailable') {
debug('store is unavailable');
throw new Error('session store is unavailable');
}
if (!this.sessionId) {
this.sessionId = sessionIdStore.get.call(this);
}
let session;
let isNew = false;
if (!this.sessionId) {
debug('session id not exist, generate a new one');
session = generateSession();
this.sessionId = genSid.call(this, 24);
isNew = true;
} else {
try {
session = yield store.get(this.sessionId);
debug('get session %j with key %s', session, this.sessionId);
} catch (err) {
if (err.code === 'ENOENT') {
debug('get session error, code = ENOENT');
} else {
debug('get session error: ', err.message);
errorHandler(err, 'get', this);
}
}
}
// make sure the session is still valid
if (!session ||
!valid(this, session)) {
debug('session is empty or invalid');
session = generateSession();
this.sessionId = genSid.call(this, 24);
sessionIdStore.reset.call(this);
isNew = true;
}
// get the originHash
let originalHash = !isNew && hash(session);
return {
originalHash: originalHash,
session: session,
isNew: isNew
};
}
/**
* after everything done, refresh the session
* if session === null; delete it from store
* if session is modified, update cookie and store
*/
function *refreshSession (session, originalHash, isNew) {
// reject any session changes, and do not update session expiry
if(this._sessionSave === false) {
return debug('session save disabled');
}
//delete session
if (!session) {
if (!isNew) {
debug('session set to null, destroy session: %s', this.sessionId);
sessionIdStore.reset.call(this);
return yield store.destroy(this.sessionId);
}
return debug('a new session and set to null, ignore destroy');
}
// force saving non-empty session
if(this._sessionSave === true) {
debug('session save forced');
return yield saveNow.call(this, this.sessionId, session);
}
let newHash = hash(session);
// if new session and not modified, just ignore
if (!options.allowEmpty && isNew && newHash === hash(generateSession())) {
return debug('new session and do not modified');
}
// rolling session will always reset cookie and session
if (!options.rolling && newHash === originalHash) {
return debug('session not modified');
}
debug('session modified');
yield saveNow.call(this, this.sessionId, session);
}
function *saveNow(id, session) {
compatMaxage(session.cookie);
// custom before save hook
beforeSave(this, session);
//update session
try {
yield store.set(id, session);
sessionIdStore.set.call(this, id, session);
debug('saved');
} catch (err) {
debug('set session error: ', err.message);
errorHandler(err, 'set', this);
}
}
/**
* common session middleware
* each request will generate a new session
*
* ```
* let session = this.session;
* ```
*/
function *session(next) {
this.sessionStore = store;
if (this.session || this._session) {
return yield next;
}
let result = yield getSession.call(this);
if (!result) {
return yield next;
}
addCommonAPI.call(this);
this._session = result.session;
// more flexible
this.__defineGetter__('session', function () {
return this._session;
});
this.__defineSetter__('session', function (sess) {
this._session = sess;
});
this.regenerateSession = function *regenerateSession() {
debug('regenerating session');
if (!result.isNew) {
// destroy the old session
debug('destroying previous session');
yield store.destroy(this.sessionId);
}
this.session = generateSession();
this.sessionId = genSid.call(this, 24);
sessionIdStore.reset.call(this);
debug('created new session: %s', this.sessionId);
result.isNew = true;
}
// make sure `refreshSession` always called
var firstError = null;
try {
yield next;
} catch (err) {
debug('next logic error: %s', err.message);
firstError = err;
}
// can't use finally because `refreshSession` is async
try {
yield refreshSession.call(this, this.session, result.originalHash, result.isNew);
} catch (err) {
debug('refresh session error: %s', err.message);
if (firstError) this.app.emit('error', err, this);
firstError = firstError || err;
}
if (firstError) throw firstError;
}
/**
* defer session middleware
* only generate and get session when request use session
*
* ```
* let session = yield this.session;
* ```
*/
function *deferSession(next) {
this.sessionStore = store;
if (this.session) {
return yield next;
}
let isNew = false;
let originalHash = null;
let touchSession = false;
let getter = false;
// if path not match
if (!matchPath(this)) {
return yield next;
}
addCommonAPI.call(this);
this.__defineGetter__('session', function *() {
if (touchSession) {
return this._session;
}
touchSession = true;
getter = true;
let result = yield getSession.call(this);
// if cookie path not match
// this route's controller should never use session
if (!result) return;
originalHash = result.originalHash;
isNew = result.isNew;
this._session = result.session;
return this._session;
});
this.__defineSetter__('session', function (value) {
touchSession = true;
this._session = value;
});
this.regenerateSession = function *regenerateSession() {
debug('regenerating session');
// make sure that the session has been loaded
yield this.session;
if (!isNew) {
// destroy the old session
debug('destroying previous session');
yield store.destroy(this.sessionId);
}
this._session = generateSession();
this.sessionId = genSid.call(this, 24);
sessionIdStore.reset.call(this);
debug('created new session: %s', this.sessionId);
isNew = true;
return this._session;
}
yield next;
if (touchSession) {
// if only this.session=, need try to decode and get the sessionID
if (!getter) {
this.sessionId = sessionIdStore.get.call(this);
}
yield refreshSession.call(this, this._session, originalHash, isNew);
}
}
};
/**
* get the hash of a session include cookie options.
*/
function hash(sess) {
return crc32.signed(JSON.stringify(sess));
}
/**
* cookie use maxAge, hack to compat connect type `maxage`
*/
function compatMaxage(opts) {
if (opts) {
opts.maxAge = opts.maxage ? opts.maxage : opts.maxAge;
delete opts.maxage;
}
}
module.exports.MemoryStore = MemoryStore;
function defaultErrorHanlder (err, type, ctx) {
err.name = 'koa-generic-session ' + type + ' error';
throw err;
}
function noop () {
return true;
}