context.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. 'use strict';
  2. const debug = require('debug')('koa-session:context');
  3. const Session = require('./session');
  4. const util = require('./util');
  5. const COOKIE_EXP_DATE = new Date(util.CookieDateEpoch);
  6. const ONE_DAY = 24 * 60 * 60 * 1000;
  7. class ContextSession {
  8. /**
  9. * context session constructor
  10. * @api public
  11. */
  12. constructor(ctx, opts) {
  13. this.ctx = ctx;
  14. this.app = ctx.app;
  15. this.opts = Object.assign({}, opts);
  16. this.store = this.opts.ContextStore ? new this.opts.ContextStore(ctx) : this.opts.store;
  17. }
  18. /**
  19. * internal logic of `ctx.session`
  20. * @return {Session} session object
  21. *
  22. * @api public
  23. */
  24. get() {
  25. const session = this.session;
  26. // already retrieved
  27. if (session) return session;
  28. // unset
  29. if (session === false) return null;
  30. // create an empty session or init from cookie
  31. this.store ? this.create() : this.initFromCookie();
  32. return this.session;
  33. }
  34. /**
  35. * internal logic of `ctx.session=`
  36. * @param {Object} val session object
  37. *
  38. * @api public
  39. */
  40. set(val) {
  41. if (val === null) {
  42. this.session = false;
  43. return;
  44. }
  45. if (typeof val === 'object') {
  46. // use the original `externalKey` if exists to avoid waste storage
  47. this.create(val, this.externalKey);
  48. return;
  49. }
  50. throw new Error('this.session can only be set as null or an object.');
  51. }
  52. /**
  53. * init session from external store
  54. * will be called in the front of session middleware
  55. *
  56. * @api public
  57. */
  58. async initFromExternal() {
  59. debug('init from external');
  60. const ctx = this.ctx;
  61. const opts = this.opts;
  62. let externalKey;
  63. if (opts.externalKey) {
  64. externalKey = opts.externalKey.get(ctx);
  65. debug('get external key from custom %s', externalKey);
  66. } else {
  67. externalKey = ctx.cookies.get(opts.key, opts);
  68. debug('get external key from cookie %s', externalKey);
  69. }
  70. if (!externalKey) {
  71. // create a new `externalKey`
  72. this.create();
  73. return;
  74. }
  75. const json = await this.store.get(externalKey, opts.maxAge, { ctx, rolling: opts.rolling });
  76. if (!this.valid(json, externalKey)) {
  77. // create a new `externalKey`
  78. this.create();
  79. return;
  80. }
  81. // create with original `externalKey`
  82. this.create(json, externalKey);
  83. this.prevHash = util.hash(this.session.toJSON());
  84. }
  85. /**
  86. * init session from cookie
  87. * @api private
  88. */
  89. initFromCookie() {
  90. debug('init from cookie');
  91. const ctx = this.ctx;
  92. const opts = this.opts;
  93. const cookie = ctx.cookies.get(opts.key, opts);
  94. if (!cookie) {
  95. this.create();
  96. return;
  97. }
  98. let json;
  99. debug('parse %s', cookie);
  100. try {
  101. json = opts.decode(cookie);
  102. } catch (err) {
  103. // backwards compatibility:
  104. // create a new session if parsing fails.
  105. // new Buffer(string, 'base64') does not seem to crash
  106. // when `string` is not base64-encoded.
  107. // but `JSON.parse(string)` will crash.
  108. debug('decode %j error: %s', cookie, err);
  109. if (!(err instanceof SyntaxError)) {
  110. // clean this cookie to ensure next request won't throw again
  111. ctx.cookies.set(opts.key, '', opts);
  112. // ctx.onerror will unset all headers, and set those specified in err
  113. err.headers = {
  114. 'set-cookie': ctx.response.get('set-cookie'),
  115. };
  116. throw err;
  117. }
  118. this.create();
  119. return;
  120. }
  121. debug('parsed %j', json);
  122. if (!this.valid(json)) {
  123. this.create();
  124. return;
  125. }
  126. // support access `ctx.session` before session middleware
  127. this.create(json);
  128. this.prevHash = util.hash(this.session.toJSON());
  129. }
  130. /**
  131. * verify session(expired or )
  132. * @param {Object} value session object
  133. * @param {Object} key session externalKey(optional)
  134. * @return {Boolean} valid
  135. * @api private
  136. */
  137. valid(value, key) {
  138. const ctx = this.ctx;
  139. if (!value) {
  140. this.emit('missed', { key, value, ctx });
  141. return false;
  142. }
  143. if (value._expire && value._expire < Date.now()) {
  144. debug('expired session');
  145. this.emit('expired', { key, value, ctx });
  146. return false;
  147. }
  148. const valid = this.opts.valid;
  149. if (typeof valid === 'function' && !valid(ctx, value)) {
  150. // valid session value fail, ignore this session
  151. debug('invalid session');
  152. this.emit('invalid', { key, value, ctx });
  153. return false;
  154. }
  155. return true;
  156. }
  157. /**
  158. * @param {String} event event name
  159. * @param {Object} data event data
  160. * @api private
  161. */
  162. emit(event, data) {
  163. setImmediate(() => {
  164. this.app.emit(`session:${event}`, data);
  165. });
  166. }
  167. /**
  168. * create a new session and attach to ctx.sess
  169. *
  170. * @param {Object} [val] session data
  171. * @param {String} [externalKey] session external key
  172. * @api private
  173. */
  174. create(val, externalKey) {
  175. debug('create session with val: %j externalKey: %s', val, externalKey);
  176. if (this.store) this.externalKey = externalKey || this.opts.genid && this.opts.genid(this.ctx);
  177. this.session = new Session(this, val, this.externalKey);
  178. }
  179. /**
  180. * Commit the session changes or removal.
  181. *
  182. * @api public
  183. */
  184. async commit() {
  185. const session = this.session;
  186. const opts = this.opts;
  187. const ctx = this.ctx;
  188. // not accessed
  189. if (undefined === session) return;
  190. // removed
  191. if (session === false) {
  192. await this.remove();
  193. return;
  194. }
  195. const reason = this._shouldSaveSession();
  196. debug('should save session: %s', reason);
  197. if (!reason) return;
  198. if (typeof opts.beforeSave === 'function') {
  199. debug('before save');
  200. opts.beforeSave(ctx, session);
  201. }
  202. const changed = reason === 'changed';
  203. await this.save(changed);
  204. }
  205. _shouldSaveSession() {
  206. const prevHash = this.prevHash;
  207. const session = this.session;
  208. // force save session when `session._requireSave` set
  209. if (session._requireSave) return 'force';
  210. // do nothing if new and not populated
  211. const json = session.toJSON();
  212. if (!prevHash && !Object.keys(json).length) return '';
  213. // save if session changed
  214. const changed = prevHash !== util.hash(json);
  215. if (changed) return 'changed';
  216. // save if opts.rolling set
  217. if (this.opts.rolling) return 'rolling';
  218. // save if opts.renew and session will expired
  219. if (this.opts.renew) {
  220. const expire = session._expire;
  221. const maxAge = session.maxAge;
  222. // renew when session will expired in maxAge / 2
  223. if (expire && maxAge && expire - Date.now() < maxAge / 2) return 'renew';
  224. }
  225. return '';
  226. }
  227. /**
  228. * remove session
  229. * @api private
  230. */
  231. async remove() {
  232. // Override the default options so that we can properly expire the session cookies
  233. const opts = Object.assign({}, this.opts, {
  234. expires: COOKIE_EXP_DATE,
  235. maxAge: false,
  236. });
  237. const ctx = this.ctx;
  238. const key = opts.key;
  239. const externalKey = this.externalKey;
  240. if (externalKey) await this.store.destroy(externalKey, { ctx });
  241. ctx.cookies.set(key, '', opts);
  242. }
  243. /**
  244. * save session
  245. * @api private
  246. */
  247. async save(changed) {
  248. const opts = this.opts;
  249. const key = opts.key;
  250. const externalKey = this.externalKey;
  251. let json = this.session.toJSON();
  252. // set expire for check
  253. let maxAge = opts.maxAge ? opts.maxAge : ONE_DAY;
  254. if (maxAge === 'session') {
  255. // do not set _expire in json if maxAge is set to 'session'
  256. // also delete maxAge from options
  257. opts.maxAge = undefined;
  258. json._session = true;
  259. } else {
  260. // set expire for check
  261. json._expire = maxAge + Date.now();
  262. json._maxAge = maxAge;
  263. }
  264. // save to external store
  265. if (externalKey) {
  266. debug('save %j to external key %s', json, externalKey);
  267. if (typeof maxAge === 'number') {
  268. // ensure store expired after cookie
  269. maxAge += 10000;
  270. }
  271. await this.store.set(externalKey, json, maxAge, {
  272. changed,
  273. ctx: this.ctx,
  274. rolling: opts.rolling,
  275. });
  276. if (opts.externalKey) {
  277. opts.externalKey.set(this.ctx, externalKey);
  278. } else {
  279. this.ctx.cookies.set(key, externalKey, opts);
  280. }
  281. return;
  282. }
  283. // save to cookie
  284. debug('save %j to cookie', json);
  285. json = opts.encode(json);
  286. debug('save %s', json);
  287. this.ctx.cookies.set(key, json, opts);
  288. }
  289. }
  290. module.exports = ContextSession;