koa-session学习笔记

作者: fankaife | 来源:发表于2018-01-29 19:33 被阅读1004次

    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,可能很多地方会有错漏,请大家指正。

    相关文章

      网友评论

      • samfung09:koa-session这个中间件好像就算不设置rolling, renew,页面每次都在max-age时间内刷新session就不会过期
      • 小菜一杰:不错哟,写得那么仔细~

      本文标题:koa-session学习笔记

      本文链接:https://www.haomeiwen.com/subject/uftzaxtx.html