session.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. /**!
  2. * koa-generic-session - lib/session.js
  3. * Copyright(c) 2013 - 2014
  4. * MIT Licensed
  5. *
  6. * Authors:
  7. * dead_horse <dead_horse@qq.com> (http://deadhorse.me)
  8. */
  9. 'use strict';
  10. /**
  11. * Module dependencies.
  12. */
  13. const debug = require('debug')('koa-generic-session:session');
  14. const MemoryStore = require('./memory_store');
  15. const crc32 = require('crc').crc32;
  16. const parse = require('parseurl');
  17. const Store = require('./store');
  18. const copy = require('copy-to');
  19. const uid = require('uid-safe');
  20. /**
  21. * Warning message for `MemoryStore` usage in production.
  22. */
  23. const warning = 'Warning: koa-generic-session\'s MemoryStore is not\n' +
  24. 'designed for a production environment, as it will leak\n' +
  25. 'memory, and will not scale past a single process.';
  26. const defaultCookie = {
  27. httpOnly: true,
  28. path: '/',
  29. overwrite: true,
  30. signed: true,
  31. maxAge: 24 * 60 * 60 * 1000 //one day in ms
  32. };
  33. /**
  34. * setup session store with the given `options`
  35. * @param {Object} options
  36. * - [`key`] cookie name, defaulting to `koa.sid`
  37. * - [`store`] session store instance, default to MemoryStore
  38. * - [`ttl`] store ttl in `ms`, default to oneday
  39. * - [`prefix`] session prefix for store, defaulting to `koa:sess:`
  40. * - [`cookie`] session cookie settings, defaulting to
  41. * {path: '/', httpOnly: true, maxAge: null, rewrite: true, signed: true}
  42. * - [`defer`] defer get session,
  43. * - [`rolling`] rolling session, always reset the cookie and sessions, default is false
  44. * you should `yield this.session` to get the session if defer is true, default is false
  45. * - [`genSid`] you can use your own generator for sid
  46. * - [`errorHanlder`] handler for session store get or set error
  47. * - [`valid`] valid(ctx, session), valid session value before use it
  48. * - [`beforeSave`] beforeSave(ctx, session), hook before save session
  49. * - [`sessionIdStore`] object with get, set, reset methods for passing session id throw requests.
  50. */
  51. module.exports = function (options) {
  52. options = options || {};
  53. let key = options.key || 'koa.sid';
  54. let client = options.store || new MemoryStore();
  55. let errorHandler = options.errorHandler || defaultErrorHanlder;
  56. let reconnectTimeout = options.reconnectTimeout || 10000;
  57. let store = new Store(client, {
  58. ttl: options.ttl,
  59. prefix: options.prefix
  60. });
  61. let genSid = options.genSid || uid.sync;
  62. let valid = options.valid || noop;
  63. let beforeSave = options.beforeSave || noop;
  64. let cookie = options.cookie || {};
  65. copy(defaultCookie).to(cookie);
  66. let storeStatus = 'available';
  67. let waitStore = Promise.resolve();
  68. // notify user that this store is not
  69. // meant for a production environment
  70. if ('production' === process.env.NODE_ENV
  71. && client instanceof MemoryStore) console.warn(warning);
  72. let sessionIdStore = options.sessionIdStore || {
  73. get: function() {
  74. return this.cookies.get(key, cookie);
  75. },
  76. set: function(sid, session) {
  77. this.cookies.set(key, sid, session.cookie);
  78. },
  79. reset: function() {
  80. this.cookies.set(key, null);
  81. }
  82. };
  83. store.on('disconnect', function() {
  84. if (storeStatus !== 'available') return;
  85. storeStatus = 'pending';
  86. waitStore = new Promise(function (resolve, reject) {
  87. setTimeout(function () {
  88. if (storeStatus === 'pending') storeStatus = 'unavailable';
  89. reject(new Error('session store is unavailable'));
  90. }, reconnectTimeout);
  91. store.once('connect', resolve);
  92. });
  93. });
  94. store.on('connect', function() {
  95. storeStatus = 'available';
  96. waitStore = Promise.resolve();
  97. });
  98. // save empty session hash for compare
  99. const EMPTY_SESSION_HASH = hash(generateSession());
  100. return options.defer ? deferSession : session;
  101. function addCommonAPI() {
  102. this._sessionSave = null;
  103. // more flexible
  104. this.__defineGetter__('sessionSave', function () {
  105. return this._sessionSave;
  106. });
  107. this.__defineSetter__('sessionSave', function (save) {
  108. this._sessionSave = save;
  109. });
  110. }
  111. /**
  112. * generate a new session
  113. */
  114. function generateSession() {
  115. let session = {};
  116. //you can alter the cookie options in nexts
  117. session.cookie = {};
  118. for (let prop in cookie) {
  119. session.cookie[prop] = cookie[prop];
  120. }
  121. compatMaxage(session.cookie);
  122. return session;
  123. }
  124. /**
  125. * check url match cookie's path
  126. */
  127. function matchPath(ctx) {
  128. let pathname = parse(ctx).pathname;
  129. let cookiePath = cookie.path || '/';
  130. if (cookiePath === '/') {
  131. return true;
  132. }
  133. if (pathname.indexOf(cookiePath) !== 0) {
  134. debug('cookie path not match');
  135. return false;
  136. }
  137. return true;
  138. }
  139. /**
  140. * get session from store
  141. * get sessionId from cookie
  142. * save sessionId into context
  143. * get session from store
  144. */
  145. function *getSession() {
  146. if (!matchPath(this)) return;
  147. if (storeStatus === 'pending') {
  148. debug('store is disconnect and pending');
  149. yield waitStore;
  150. } else if (storeStatus === 'unavailable') {
  151. debug('store is unavailable');
  152. throw new Error('session store is unavailable');
  153. }
  154. if (!this.sessionId) {
  155. this.sessionId = sessionIdStore.get.call(this);
  156. }
  157. let session;
  158. let isNew = false;
  159. if (!this.sessionId) {
  160. debug('session id not exist, generate a new one');
  161. session = generateSession();
  162. this.sessionId = genSid.call(this, 24);
  163. isNew = true;
  164. } else {
  165. try {
  166. session = yield store.get(this.sessionId);
  167. debug('get session %j with key %s', session, this.sessionId);
  168. } catch (err) {
  169. if (err.code === 'ENOENT') {
  170. debug('get session error, code = ENOENT');
  171. } else {
  172. debug('get session error: ', err.message);
  173. errorHandler(err, 'get', this);
  174. }
  175. }
  176. }
  177. // make sure the session is still valid
  178. if (!session ||
  179. !valid(this, session)) {
  180. debug('session is empty or invalid');
  181. session = generateSession();
  182. this.sessionId = genSid.call(this, 24);
  183. sessionIdStore.reset.call(this);
  184. isNew = true;
  185. }
  186. // get the originHash
  187. let originalHash = !isNew && hash(session);
  188. return {
  189. originalHash: originalHash,
  190. session: session,
  191. isNew: isNew
  192. };
  193. }
  194. /**
  195. * after everything done, refresh the session
  196. * if session === null; delete it from store
  197. * if session is modified, update cookie and store
  198. */
  199. function *refreshSession (session, originalHash, isNew) {
  200. // reject any session changes, and do not update session expiry
  201. if(this._sessionSave === false) {
  202. return debug('session save disabled');
  203. }
  204. //delete session
  205. if (!session) {
  206. if (!isNew) {
  207. debug('session set to null, destroy session: %s', this.sessionId);
  208. sessionIdStore.reset.call(this);
  209. return yield store.destroy(this.sessionId);
  210. }
  211. return debug('a new session and set to null, ignore destroy');
  212. }
  213. // force saving non-empty session
  214. if(this._sessionSave === true) {
  215. debug('session save forced');
  216. return yield saveNow.call(this, this.sessionId, session);
  217. }
  218. let newHash = hash(session);
  219. // if new session and not modified, just ignore
  220. if (!options.allowEmpty && isNew && newHash === EMPTY_SESSION_HASH) {
  221. return debug('new session and do not modified');
  222. }
  223. // rolling session will always reset cookie and session
  224. if (!options.rolling && newHash === originalHash) {
  225. return debug('session not modified');
  226. }
  227. debug('session modified');
  228. yield saveNow.call(this, this.sessionId, session);
  229. }
  230. function *saveNow(id, session) {
  231. compatMaxage(session.cookie);
  232. // custom before save hook
  233. beforeSave(this, session);
  234. //update session
  235. try {
  236. yield store.set(id, session);
  237. sessionIdStore.set.call(this, id, session);
  238. debug('saved');
  239. } catch (err) {
  240. debug('set session error: ', err.message);
  241. errorHandler(err, 'set', this);
  242. }
  243. }
  244. /**
  245. * common session middleware
  246. * each request will generate a new session
  247. *
  248. * ```
  249. * let session = this.session;
  250. * ```
  251. */
  252. function *session(next) {
  253. this.sessionStore = store;
  254. if (this.session || this._session) {
  255. return yield next;
  256. }
  257. let result = yield getSession.call(this);
  258. if (!result) {
  259. return yield next;
  260. }
  261. addCommonAPI.call(this);
  262. this._session = result.session;
  263. // more flexible
  264. this.__defineGetter__('session', function () {
  265. return this._session;
  266. });
  267. this.__defineSetter__('session', function (sess) {
  268. this._session = sess;
  269. });
  270. this.regenerateSession = function *regenerateSession() {
  271. debug('regenerating session');
  272. if (!result.isNew) {
  273. // destroy the old session
  274. debug('destroying previous session');
  275. yield store.destroy(this.sessionId);
  276. }
  277. this.session = generateSession();
  278. this.sessionId = genSid.call(this, 24);
  279. sessionIdStore.reset.call(this);
  280. debug('created new session: %s', this.sessionId);
  281. result.isNew = true;
  282. }
  283. // make sure `refreshSession` always called
  284. var firstError = null;
  285. try {
  286. yield next;
  287. } catch (err) {
  288. debug('next logic error: %s', err.message);
  289. firstError = err;
  290. }
  291. // can't use finally because `refreshSession` is async
  292. try {
  293. yield refreshSession.call(this, this.session, result.originalHash, result.isNew);
  294. } catch (err) {
  295. debug('refresh session error: %s', err.message);
  296. if (firstError) this.app.emit('error', err, this);
  297. firstError = firstError || err;
  298. }
  299. if (firstError) throw firstError;
  300. }
  301. /**
  302. * defer session middleware
  303. * only generate and get session when request use session
  304. *
  305. * ```
  306. * let session = yield this.session;
  307. * ```
  308. */
  309. function *deferSession(next) {
  310. this.sessionStore = store;
  311. if (this.session) {
  312. return yield next;
  313. }
  314. let isNew = false;
  315. let originalHash = null;
  316. let touchSession = false;
  317. let getter = false;
  318. // if path not match
  319. if (!matchPath(this)) {
  320. return yield next;
  321. }
  322. addCommonAPI.call(this);
  323. this.__defineGetter__('session', function *() {
  324. if (touchSession) {
  325. return this._session;
  326. }
  327. touchSession = true;
  328. getter = true;
  329. let result = yield getSession.call(this);
  330. // if cookie path not match
  331. // this route's controller should never use session
  332. if (!result) return;
  333. originalHash = result.originalHash;
  334. isNew = result.isNew;
  335. this._session = result.session;
  336. return this._session;
  337. });
  338. this.__defineSetter__('session', function (value) {
  339. touchSession = true;
  340. this._session = value;
  341. });
  342. this.regenerateSession = function *regenerateSession() {
  343. debug('regenerating session');
  344. // make sure that the session has been loaded
  345. yield this.session;
  346. if (!isNew) {
  347. // destroy the old session
  348. debug('destroying previous session');
  349. yield store.destroy(this.sessionId);
  350. }
  351. this._session = generateSession();
  352. this.sessionId = genSid.call(this, 24);
  353. sessionIdStore.reset.call(this);
  354. debug('created new session: %s', this.sessionId);
  355. isNew = true;
  356. return this._session;
  357. }
  358. yield next;
  359. if (touchSession) {
  360. // if only this.session=, need try to decode and get the sessionID
  361. if (!getter) {
  362. this.sessionId = sessionIdStore.get.call(this);
  363. }
  364. yield refreshSession.call(this, this._session, originalHash, isNew);
  365. }
  366. }
  367. };
  368. /**
  369. * get the hash of a session include cookie options.
  370. */
  371. function hash(sess) {
  372. return crc32.signed(JSON.stringify(sess));
  373. }
  374. /**
  375. * cookie use maxage, hack to compat connect type `maxAge`
  376. */
  377. function compatMaxage(opts) {
  378. if (opts) {
  379. opts.maxage = opts.maxage === undefined
  380. ? opts.maxAge
  381. : opts.maxage;
  382. delete opts.maxAge;
  383. }
  384. }
  385. module.exports.MemoryStore = MemoryStore;
  386. function defaultErrorHanlder (err, type, ctx) {
  387. err.name = 'koa-generic-session ' + type + ' error';
  388. throw err;
  389. }
  390. function noop () {
  391. return true;
  392. }