今天看啥  ›  专栏  ›  shawncheung

深入理解 session

shawncheung  · 掘金  · 前端  · 2018-06-18 13:59

文章预览

由于 HTTP 协议的无状态特点, 我们在平时的开发中为了良好的用户体验, 往往会需要 cookie 或者 session 的介入, 特别是在授权与登录认证方面. 这里我通过编写一个简单的 session 中间件来深入理解一下 session.

session 的本质

我们知道 session 与 cookie 是分不开的, session 其实是基于 cookie 的, 所以如果你的浏览器禁用了 cookie 的话, 那么 session 也会用不了. 为什么需要 cookie 与 session 呢? 是因为 HTTP 协议是无状态的, 上一次的请求与下一次的请求之间并无关联, 所以我们需要某些东西来记住我们上一次是做了什么操作, 然后在下一个请求才能顺延上一步的结果.
例如, 我们整个网站中的关键操作, 比如购物, 将物品放入购物车, 对某个文档进行修改等等这些操作, 都需要验证了身份之后才可以进行操作, 但是我们在用户体验的角度上来看, 不可能在用户每次执行这些操作的时候, 都弹出一个登录框来要求用户进行登录, 这样严重降低了良好的用户体验, 所以我们就需要利用 session 来记住用户的登录态, 让操作更流畅.

我们知道, 对于简单的 Cookie 来说, 我们需要设置 Cookie 的 domain, path, expires 基本属性值, 这样我们就可以在客户端存储数据了, 而且这样的数据是每次请求的时候都会在 HTTP 的头部带上值的, 也就是说我们可以在请求头部中获取数据, 并且做相对应的操作, 当然服务端也可以通过 Set-Cookie 响应头部来设置 Cookie. 而 Session 是基于 Cookie 的, 也就是说, 我们在设置一个 Session 的时候, 实际上是设置了一个 Cookie 的键值对.

Set-Cookie: key=value; path=/; Expires=3000;

而 key 是自己定的, 用来读取对应的 session 在客户端的信息, 而在客户端存储的 session 的数据, 就是 sessionId.我们利用这个 sessionId 在服务端存储了对应的数据, 也即是说, sessionId 在服务端对应的是一个对象, 里面存储了很多数据:

// store 是一个服务端的简单对象
store: {
    sessionId1: {
        nick: 'shawn'
    },
    sessionId2: {
        nick: 'zhang'
    },
    sessionId3: {
        nick: 'cheung'
    }
}

服务端存储数据的优势

那么, 既然 cookie 本身就可以存储数据, 我们何必使用 seesion 呢? 这个在于 cookie 与 session 其实都是为了解决 HTTP 协议无状态这个问题的, 只不过 Cookie 是客户端方案, Session 是服务端方案, 虽然面对不同的场景, 两者有其适合的用法, 但是对比起 Cookie, Seesion 在信息保护方面可能会更好, 因为一般 cookie 的数据都不加密, 这意味着是明文, 甚至用户可以通过浏览器的开发者工具就可以直接看到, 不便于存储一些敏感的数据, 但是 session 在客户端只有一个 sessionId, 其对应的数据存储在服务端, 增强了安全指数. 而且另一方面, cookie 头部在每次请求都会带上, 如果 cookie 的数据过多这无疑增加了每次传输数据的网络负担, 而使用 session 可以减轻这方面的负荷.

如何存储与处理 session

为了更好地理解 session 机制, 我们来试试造一个 session 中间件:
先创建存储相关的对象, 我们这里需要将存储相关的操作分离出来在 memory_store.js 里面, 这样的好处在于能够统一接口, 方便接入 mysql, redis 等第三方数据库客户端来存储 session 数据

module.exports = class MemoryStore {
  constructor () {
    // 存储全部的 session 数据
    this.store = {};
    // 存储 ttl 的定时器 id
    this.timers = {};
  }

  get (sid) {
    if (!sid) return undefined;
    return this.store[sid];
  }

  set (sid, value, ttl) {
    // 重新设置之前先清除之前的定时器, 防止内存泄漏
    if (this.store[sid]) clearTimeout(this.timers[sid]);
    // 保存值
    this.store[sid] = value;
    // 设置多少时间后清除数据与定时器
    this.timers[sid] = setTimeout(() => {
      Reflect.deleteProperty(this.store, sid);
      Reflect.deleteProperty(this.timers, sid);
    }, +ttl);
  }
 
  destory (sid) {
    // 与上面逻辑类似, 但是是对应某个对应的 sid 的
    clearTimeout(this.timers[sid]);

    Reflect.deleteProperty(this.store, sid);
    Reflect.deleteProperty(this.timers, sid);
  }
};

下面创建一个简单的基于 koa 框架的 session 中间件(实现上参考了 koa-session-minimal):

const MemoryStore = require('./memory_store');

const ONE_DAY = 1000 * 3600 * 24;
// 获取 cookie 相关
const getCookieOpts = (opts = {}) => {
  const options = Object.assign({
    maxAge: 0,
    path: '/',
    httpOnly: true
  }, opts);
  if (!(options.maxAge >= 0)) options.maxAge = 0;
  return options;
};
// 删除 session 数据
const deleteSession = (ctx, key, cookieOpts, store, sid) => {
  const tmp = Object.assign({}, cookieOpts);
  Reflect.deleteProperty(tmp, 'maxAge');
  // 设置一个空值的 cookie 为删除
  ctx.cookies.set(key, null, tmp);
  // 删除 session 对象中的数据
  store.destory(`${key}:${sid}`);
};
// 保存 session 数据
const saveSession = (ctx, key, cookieOpts, store, sid) => {
  const ttl = cookieOpts.maxAge > 0 ? cookieOpts.maxAge : ONE_DAY;
  // 设置 cookie 值
  ctx.cookies.set(key, sid, cookieOpts);
  // 在 session 对象保存值
  store.set(`${key}:${sid}`, ctx.session, ttl);
};
// 检查 session 的类型, 保证 session 是一个对象
const checkSession = (ctx) => {
  if (!ctx.session || typeof ctx.session !== 'object') ctx.session = {};
};

module.exports = function Session (options = {}) {
  const opt = options;
  const key = opt.key || 'session:middlewares';
  const cookie = getCookieOpts(opt.cookie);
  const store = opt.store || new MemoryStore();

  return async (ctx, next) => {
    const oldSid = ctx.cookies.get(key);
    
    let sid = oldSid;
    // 暴露改变 sessionId 的接口
    ctx.sessionHandler = {
      generatorId: () => {
        // 随机的字符串
        sid = Math.random().toString(36).substr(2);
        return sid;
      }
    }
    // 如果没有 sid, 也就是之前没有设置过
    if (!sid) {
      // 生成 sid
      ctx.sessionHandler.generatorId();
      // 这里的 session 对象只是一个暂存对象, 最后的数据是放到 store 的对象里面的
      ctx.session = {};
    } else {
      // 取出对应的值
      ctx.session = store.get(`${key}:${sid}`);
      // 保证 session 是对像值
      checkSession(ctx);
    }
    
    await next();

    const hasData = Object.keys(ctx.session).length > 0;
    // 如果 sid 没有变化
    if (sid === oldSid) {
      // 如果数据没有变化, 那么就直接退出
      if (JSON.stringify(ctx.session) === JSON.stringify(store.get(`${key}:${sid}`))) return;
      if (hasData) {
        saveSession(ctx, key, cookie, store, sid);
      } else {
        deleteSession(ctx, key, cookie, store, sid);
      }
    } else { // 如果没有 oldSid 或者 sid 发生了变化
      if (oldSid) deleteSession(ctx, key, cookie, store, sid);
      if (hasData) saveSession(ctx, key, cookie, store, sid);
    }
  }
};

因为要标记客户端, 所以需要确保 sessionId 的唯一性. 其实上面的逻辑很简单, 其实总得来说就是 seesion 需要生成一个唯一的 id, 对应服务端的全局 session 对象中的某个次级对象, 里面存储着更多数据, 然后在请求来临的时候判断 cookie 是否有我们预设的 key 对应的 sessionId 值, 如果没有则生成一个, 有则接下一步, 然后将数据暂时放在请求上下文的 session 属性中, 当请求处理完毕后, 将数据放入到真正的全局对象中去.

session 的安全性

经过上面, 我们大致明白了 session 的处理过程, 那么它存不存在安全问题呢? 答案是必然的.

为什么需要设置 httpOnly 属性

我们在使用 session 的时候, 设置 cookie 的时候需要保证 cookie 的 httpOnly 属性值被设置为 true. 因为客户端的数据并不值得信任, 我们需要确保 sessionId 不会被读写, 如果能够被读, 那么就可以进行伪造, 如果可以进行写, 那么就可以修改, 我们不能允许这样的情况出现, 所以我们需要设置 cookie 的 httpOnly 属性, 这样客户端的脚本是不能对这个 cookie 值进行读写的, 意味着 js 不能对此值进行读写操作, 在一定的程度上防范了 XSS 攻击(跨站脚本工具).

为什么 sessionId 需要随机

这个问题的答案是显而易见的, 攻击者需要的就是知道 sessionId 与真正数据的对应关系, 比如攻击者拥有了班级的学号与姓名的对应表, 正好开发者在实现的时候, 使用了这个对应关系, 比如张三的学号为 1, 李四的学号为 2, 而他们登录信息对应的 sessionId 正好就是 1 与 2. 那么知道了这个关系, 攻击者现在就可以随心所欲地进行登录伪造了. 所以我们需要对 sessionId 进行随机化, 让攻击者猜不透 id 与数据的对应关系, 甚至在过期之后我们需要及时更换对应的 id, 这样才能提高安全性.

………………………………

原文地址:访问原文地址
快照地址: 访问文章快照
总结与预览地址:访问总结与预览