koa-session学习笔记

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

session基础知识

这部分算是基础知识,熟悉的朋友能够跳过。java

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

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

当咱们讨论session的实现方式的时候,都是寻找一种方式从而使得屡次请求之间可以共享一些信息。不论选择哪一种方式,都是须要由服务本身来实现的,http协议并不提供原生的支持。git

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

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

session vs token

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

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

  • 用户登陆的时候,服务端生成一个会话和一个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/11... 写到一半的时候才发现这篇文章,对于session总体流程也讲的挺清楚的,能够对着一块儿看
  • 由于koa-session的代码比较简单,有时间的话对着源码调试一下很容易搞懂
  • 初学js和node,可能不少地方会有错漏,请你们指正。
相关文章
相关标签/搜索