koa-session学习笔记

koa-session是koa的session管理中间件,最近在写登录注册模块的时候学习了一下这部分的代码,感觉还比较容易看明白,让自己对于session的理解也更加深入了,这里总结一下。

session基础知识

这部分算是基础知识,熟悉的朋友可以跳过。

我们都知道http协议本身是无状态的,因此协议本身是不支持“登录状态”这样的概念的,必须由项目自己来实现。我们常常说到session这个概念,但是可能有人并不是非常清楚我们讨论的session具体指代什么。我觉得这个概念比较容易混淆,不同的上下文会有不同的含义:

  • session首先是一个抽象的概念,指代多个有关联的http请求所构成的一个会话。
  • session常常用来指代为了实现一个会话,需要在客户端和服务端之间传输的信息。这些信息可以是会话所需的所有内容(包括用户身份、相关数据等),也可以只是一个id,让服务端可能从后台检索到相关数据,这也是实际系统中最常用的方式。

当我们讨论session的实现方式的时候,都是寻找一种方式从而使得多次请求之间能够共享一些信息。不论选择哪种方式,都是需要由服务自己来实现的,http协议并不提供原生的支持。

实现session的一种方式就是在每个请求的参数或者数据中带上相关信息,这种方式的好处是不受cookie可用性的限制。我们在登录某些网站的时候会发现url里有长长的一串不规则字符,往往就是编码了用户的session信息。但是这种方式也会受到请求长度的限制,使用起来也不方便,而且还有安全性上的隐患

最常见的方式还是使用cookie来存储session信息。如上所述,这里的信息可以是整个session的具体数据,也可以只是session的标识。这样服务端通过set-cookie的方式把信息返回给客户端,客户端下次请求的时候会自动带上符合条件的cookie,服务端再解析cookie就能够获取到session信息了。koa-session也是采用cookie来实现session,默认情况下只使用一个cookie字段来存储session信息。

session vs token

在进入koa-session的讨论之前,简单聊聊token。session和token都常常用来作为用户鉴权的机制。

大部分情况下,当我们提到session鉴权的时候,指的是这样一个流程

  • 用户登录的时候,服务端生成一个会话和一个id标识
  • 会话id在客户端和服务端之间通过cookie进行传输
  • 服务端通过会话id可以获取到会话相关的信息,然后对客户端的请求进行响应;如果找不到有效的会话,那么认为用户是未登陆状态
  • 会话会有过期时间,也可以通过一些操作(比如登出)来主动删除

token的典型流程为:

  • 用户登录的时候,服务端生成一个token返回给客户端
  • 客户端后续的请求都带上这个token
  • 服务端解析token获取用户信息,并响应用户的请求
  • token会有过期时间,客户端登出的时候也会废弃token,但是服务端不需要任何操作

两种方式的区别在于:

  • session要求服务端存储信息,并且根据id能够检索,而token不需要。在大规模系统中,对每个请求都检索会话信息可能是一个复杂和耗时的过程。但另外一方面服务端要通过token来解析用户身份也需要定义好相应的协议。
  • session一般通过cookie来交互,而token方式更加灵活,可以是cookie,也可以是其他header,也可以放在请求的内容中。不使用cookie可以带来跨域上的便利性。
  • token的生成方式更加多样化,可以由第三方服务来提供

很多情况下,session和token两种方式都会一起来使用。

koa-session使用方式

最简单的代码如下所示

const session = require('koa-session');
const Koa = require('koa');
const app = new Koa();
app.keys = ['some secret hurr'];

const CONFIG = {
  key: 'koa:sess', /** (string) cookie key (default is koa:sess) */
  /** (number || 'session') maxAge in ms (default is 1 days) */
  /** 'session' will result in a cookie that expires when session/browser is closed */
  /** Warning: If a session cookie is stolen, this cookie will never expire */
  maxAge: 86400000,
  overwrite: true, /** (boolean) can overwrite or not (default true) */
  httpOnly: true, /** (boolean) httpOnly or not (default true) */
  signed: true, /** (boolean) signed or not (default true) */
  rolling: false, /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */
  renew: false, /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/
};
app.use(session(CONFIG, app));

app.use(ctx => {
  // ignore favicon
  if (ctx.path === '/favicon.ico') return;

  let n = ctx.session.views || 0;
  ctx.session.views = ++n;
  ctx.body = n + ' views';
});

app.listen(3000);

我们看到这个在这个回话状态中,session中保存了页面访问次数,每次请求的时候,会增加计数再把结果返回给用户。

koa-session的代码结构很简单

index.js          // 定义主流程和扩展context
 \- context.js    // 定义SessionContext类,定义了对session的主要操作
 \- session.js    // 定义session类,只有一些简单的util
 \- util.js       // 对session进行编码解码的util

在使用koa-session的时候用户可以传一个自定义的config,包括:

  1. maxAge,这个是确定cookie的有效期,默认是一天。
  2. rolling, renew,这两个都是涉及到cookie有效期的更新策略
  3. httpOnly,表示是否可以通过javascript来修改,设成true会更加安全
  4. signed,这个涉及到cookie的安全性,下面再讨论
  5. store,可以传入一个用于session的外部存储

koa-session主要流程

我们可以先直接看看koa-session的代码入口,我加了一些简单的注释

// https://github.com/koajs/session/blob/master/index.js
module.exports = function(opts, app) {
  // ... 省略部分代码
  opts = formatOpts(opts);
  extendContext(app.context, opts);
  return async function session(ctx, next) {
    const sess = ctx[CONTEXT_SESSION];  // 获取当前的session,这里设置了一个getter,首次访问时会创建一个新的ContextSession
    if (sess.store) await sess.initFromExternal(); // 如果设置了使用外部存储,就从外部存储初始化
    try {
      await next();
    } catch (err) {
      throw err;
    } finally {
      await sess.commit();
    }
  };
};

可以看到koa-session的基本流程非常简单

  1. 根据cookie或者外部存储初始化cookie。
  2. 调用next()执行后面的业务逻辑,其中可以读取和写入新的session内容。
  3. 调用commit()把更新后的session保存下来。

session存储

对于session的存储方式,koa-session同时支持cookie和外部存储。

默认配置下,会使用cookie来存储session信息,也就是实现了一个"cookie session"。这种方式对服务端是比较轻松的,不需要额外记录任何session信息,但是也有不少限制,比如大小的限制以及安全性上的顾虑。用cookie保存时,实现上非常简单,就是对session(包括过期时间)序列化后做一个简单的base64编码。其结果类似
koa:sess=eyJwYXNzcG9ydCI6eyJ1c2VyIjozMDM0MDg1MTQ4OTcwfSwiX2V4cGlyZSI6MTUxNzI3NDE0MTI5MiwiX21heEFnZSI6ODY0MDAwMDB9;

在实际项目中,会话相关信息往往需要再服务端持久化,因此一般都会使用外部存储来记录session信息。外部存储可以是任何的存储系统,可以是内存数据结构,也可以是本地的文件,也可以是远程的数据库。但是这不意味着我们不需要cookie了,由于http协议的无状态特性,我们依然需要通过cookie来获取session的标识(这里叫externalKey)。koa-session里的external key默认是一个时间戳加上一个随机串,因此cookie的内容类似
koa:sess=1517188075739-wnRru1LrIv0UFDODDKo8trbmFubnVmMU;

要实现一个外置的存储,用户需要自定义get(), set()和destroy()函数,分别用于获取、更新和删除session。一个最简单的实现,我们就采用一个object来存储session,那么可以这么来配置

let store = {
  storage: {},
  get (key, maxAge) {
    return this.storage[key]
  },
  set (key, sess, maxAge) {
    this.storage[key] = sess
  },
  destroy (key) {
    delete this.storage[key]
  }
}
app.use(session({store}, app))

session初始化

了解了session的存储方式,就很容易了解session的初始化过程了。
在上面的koa-session主要流程中, 可以看到调用了extendContext(app.context, opts),其作用是给context扩充了一些内容,代码如下

// https://github.com/koajs/session/blob/master/index.js
function extendContext(context, opts) {
  Object.defineProperties(context, {
    [CONTEXT_SESSION]: {
      get() {
        if (this[_CONTEXT_SESSION]) return this[_CONTEXT_SESSION];
        this[_CONTEXT_SESSION] = new ContextSession(this, opts);
        return this[_CONTEXT_SESSION];
      },
    },
    session: {
      get() {
        return this[CONTEXT_SESSION].get();
      },
      set(val) {
        this[CONTEXT_SESSION].set(val);
      },
      configurable: true,
    },
    sessionOptions: {
      get() {
        return this[CONTEXT_SESSION].opts;
      },
    },
  });
}

_CONTEXT_SESSION字段是一个ContextSession,这是对真正的session的一个holder。这里定义了一个getter,用于在首次调用时新建一个ContextSession对象。

session字段就是用于读写ContextSession里的session字段。这里有一点奇怪的是,从cookie初始化是在首次调用ContextSession.get()的时候才进行,而从外部存储初始化则是在主流程中就调用了。

ContextSession类定义在koa-session库的context.js文件中,其get()函数代码如下

// https://github.com/koajs/session/blob/master/lib/context.js
  get() {
    const session = this.session;
    // already retrieved
    if (session) return session;
    // unset
    if (session === false) return null;

    // cookie session store
    if (!this.store) this.initFromCookie();
    return this.session;
  }

initFromCookie()就是从cookie的初始化过程,代码很简单,我加了一点注释,最需要注意的就是生成一个prevHash来标记当前状态

// https://github.com/koajs/session/blob/master/lib/context.js  
  initFromCookie() {
    debug('init from cookie');
    const ctx = this.ctx;
    const opts = this.opts;

    // FK: 获取cookie,如果不存在就调用create()新建一个空的session
    const cookie = ctx.cookies.get(opts.key, opts);
    if (!cookie) {
      this.create();
      return;
    }

    let json;
    debug('parse %s', cookie);
    try {
      // FK: 解析base64编码的cookie内容
      json = opts.decode(cookie);
    } catch (err) {
      // FK: 省略错误处理内容
    }

    debug('parsed %j', json);
    
    // FK: 对于session检查有效性,如果失败(比如已经过期)就新建一个session
    if (!this.valid(json)) {
      this.create();
      return;
    }

    // support access `ctx.session` before session middleware
    // FK: 根据cookie的内容来创建session
    this.create(json);
    // FK: *** 记录当前session的hash值,用于在业务流程完成判断是否有更新 ***
    this.prevHash = util.hash(this.session.toJSON());
  }

initFromExternal()就是从外部存储初始化session,和cookie初始化类似

  async initFromExternal() {
    debug('init from external');
    const ctx = this.ctx;
    const opts = this.opts;

    // FK: 对于外部存储,cookie中的内容就是external key
    const externalKey = ctx.cookies.get(opts.key, opts);
    debug('get external key from cookie %s', externalKey);
    
    // FK: 如果external key不存在,就新建一个
    if (!externalKey) {
      // create a new `externalKey`
      this.create();
      return;
    }

    // FK: 如果在外部存储中找不到相应的session,就新建一个
    const json = await this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling });
    if (!this.valid(json, externalKey)) {
      // create a new `externalKey`
      this.create();
      return;
    }

    // create with original `externalKey`
    // FK: 根据外部存储的内容来创建session
    this.create(json, externalKey);
    // FK: *** 记录当前session的hash值,用于在业务流程完成判断是否有更新 ***
    this.prevHash = util.hash(this.session.toJSON());
  }

session提交

主流程我们已经看到,在业务逻辑处理之后,会调用sess.commit()来提交修改后的session。根据session的存储方式,提交的session会保存到cookie中或者是外部存储中。

  async commit() {
    const session = this.session;
    const opts = this.opts;
    const ctx = this.ctx;

    // not accessed
    if (undefined === session) return;

    // removed
    if (session === false) {
      await this.remove();
      return;
    }

    const reason = this._shouldSaveSession();
    debug('should save session: %s', reason);
    if (!reason) return;

    if (typeof opts.beforeSave === 'function') {
      debug('before save');
      opts.beforeSave(ctx, session);
    }
    const changed = reason === 'changed';
    await this.save(changed);
  }

commit()的过程就是判断是否要保存/删除cookie,删除的条件比较简单,保存cookie的条件又调用了_shouldSaveSession(),代码如下

 _shouldSaveSession() {
    // 省略部分代码。。。

    // save if session changed
    const changed = prevHash !== util.hash(json);
    if (changed) return 'changed';

    // save if opts.rolling set
    if (this.opts.rolling) return 'rolling';

    // save if opts.renew and session will expired
    if (this.opts.renew) {
      const expire = session._expire;
      const maxAge = session.maxAge;
      // renew when session will expired in maxAge / 2
      if (expire && maxAge && expire - Date.now() < maxAge / 2) return 'renew';
    }

    return '';
  }

可见保存session的情况包括

  1. 如果session有变动
  2. 在config里设置了rolling为true,也就是每次都更新session
  3. 在config里设置了renew为true,且有效期已经过了一半,需要更新session

一旦满足任何一个条件,就会调用save()操作来保存cookie

  async save(changed) {
    // 省略部分代码。。。
    // save to external store
    if (externalKey) {
      debug('save %j to external key %s', json, externalKey);
      if (typeof maxAge === 'number') {
        // ensure store expired after cookie
        maxAge += 10000;
      }
      await this.store.set(externalKey, json, maxAge, {
        changed,
        rolling: opts.rolling,
      });
      this.ctx.cookies.set(key, externalKey, opts);
      return;
    }

    // save to cookie
    debug('save %j to cookie', json);
    json = opts.encode(json);
    debug('save %s', json);

    this.ctx.cookies.set(key, json, opts);
  }

和初始化类似,save()操作也是分为cookie存储和外部存储两种方式分别操作。
至此,对于session的基本操作流程应该都已经清楚了。

安全性

如果session采用外部存储的方式,安全性是比较容易保证的,因为cookie中保存的只是session的external key,默认实现是一个时间戳加随机字符串,因此不用担心被恶意篡改或者暴露信息。当然如果cookie本身被窃取,那么在过期之前还是可以被用来访问session信息(当然我们可以在标识中加入更多的信息,比如ip地址,设备id等信息,从而增加更多校验来减少风险)。

如果session完全保存在cookie中,就需要额外注意安全性的问题。在session的默认实现中,我们注意到对cookie的编码只是简单的base64,因此理论上客户端很容易解析和修改。

因此在koa-session的config中有一个httpOnly的选项,就是不允许浏览器中的js代码来获取cookie,避免遭到一些恶意代码的攻击。

但是假如cookie被窃取,攻击者还是可以很容易的修改cookie,比如把maxAge设为无限就可以一直使用cookie了,这种情况如何处理呢?其实是koa的cookie本身带了安全机制,也就是config里的signed设为true的时候,会自动给cookie加上一个sha256的签名,类似koa:sess.sig=pjadZtLAVtiO6-Haw1vnZZWrRm8,从而防止cookie被篡改。

最后,如何处理session的信息被泄露的问题呢?其实koa-session允许用户在config中配置自己的编码和解码函数,因此完全可以使用自定义的加密解密函数对session进行编解码,类似

  encode: json => CryptoJS.AES.encrypt(json, "Secret Passphrase"),
  decode: encrypted => CryptoJS.AES.decrypt(encrypted, "Secret Passphrase");

尾记

  • https://segmentfault.com/a/1190000012412299 写到一半的时候才发现这篇文章,对于session整体流程也讲的挺清楚的,可以对着一起看
  • 因为koa-session的代码比较简单,有时间的话对着源码调试一下很容易搞懂
  • 初学js和node,可能很多地方会有错漏,请大家指正。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容