Session 最佳实践

由来

Session 的根本目的增强 HTTP 的状态,这里说的是增强。HTTP 是无状态的,Cookie 才是赋予无状态的 HTTP 以有状态的功能。但是 Cookie 易被修改、易被冒用,于是出来个 JsonWebToken 修复了 Cookie 的缺点。Cookie 与 Token 的区别与优缺点。。。但是把状态过多的存在客户端毕竟浪费流量,而且也不好控制。例如要实现多端登录,单端管理登录信息的功能,这就必须使用 session 了。

既然 Cookie/JWT 已经赋予了 HTTP 以状态,那么在某些仅需要已经在 Cookie/JWT 存储的状态,而且对安全性要求不是很严格的接口中,完全可以不去获取完整的 session,节约一次数据库请求。只在需要获取完整的状态,或者在需要绝对判断该认证是否有效,是否已经被删除的时候,才去做数据库请求获取完整的 session。因此,我们需要 Lazy Session

API 设计

因为是 Lazy 的,而且从数据库获取完整的 session 是一次异步操作,所以 ctx.session 应该是一个返回 Promise 的异步方法,我们也可以根据函数的参数的数量来决定 session 的初始化、修改、删除。另外,目前 koa/express 上的 session 中间件几乎全都是自动帮我们把 session_id(以下简称为 sid) 设置到 cookie 上,这样,使用起来方便倒是方便了,但是鉴于上面有说到的 Cookie 与 Token 的区别与优缺点,有的人可能不希望把 sid 放在 cookie 上,希望把 sid 通过 jwt 甚至别的形式传给服务器,所以定死怎么从请求中得到该请求的 sid 的方法可能并不是很好。当然,既然 sid 并不一定存储在 cookie 中,中间件也就无法通过 Set-Cookie 来告诉客户端存储 sid,中间件同样也不能覆盖响应的 body 去告诉客户端存储 sid。所以,整体流程应该是:客户端登录,登陆成功,服务端返回响应,响应 body 中包含新建 session 中的 sid,然后客户端把 sid 放在对应的位置去发起新的请求。

Session Store 设计

为了让应用能够横向扩展,session 显然应该存储在一个第三方服务中,例如 redis 或者 关系数据库,而不是直接存在内存中。但是如果我们要求的更多一点,比如需要能对 session 进行检索,例如要实现实现多端登录,单端管理登录信息的功能,就必须得根据 session 中的 user_id 进行检索,这里就需要把 session 存储在一个关系数据库中可能比较恰当。不过目前的使用关系数据库作为 session store 的 nodejs package 中,大部分的 session 表只有 sid: string, sess: json, expired_at: Date 这三个关键字段,无法对 sess 中的 user_id 进行检索。因此,我们需要 Schema Session,由使用者自定义 session 表的可检索列。

示例

require('dotenv').config();

const knex = require('knex')(JSON.parse(process.env.KNEX_CONFIG));

function sign(payload, options) {
  return require('jsonwebtoken').sign(payload, process.env.JWT_SECRET, options);
}
function verify(token, options) {
  try {
    return require('jsonwebtoken').verify(token, process.env.JWT_SECRET, options);
  } catch (error) {
    return null;
  }
}
const jwt = { sign, verify };

const uuid = require('uuid');
const Koa = require('koa');
const route = require('koa-route');

const app = new Koa();
app.use(require('koa-body')());

app.use(async function parse_jwt(ctx, next) {
  let token = (ctx.request.headers['authorization'] || '').replace('Bearer ', '');
  ctx.state.jwt = jwt.verify(token);
  await next();
});

const { default: session } = require('koa-lazy-multi-session');
const { default: Store } = require('knex-schema-session-store');
const store = new Store(knex, {
  schemas: [
    { name: 'user_id', type: 'string', extra: cb=>cb.notNullable() }
  ]
});

app.use(session({
  get_sid: ctx => ctx.state.jwt && ctx.state.jwt.sid,
  store,
}));

// 初始化 session
app.use(route.post('/login', async function(ctx, next) {
  // 判断登录成功,得到 user 和一些其他的信息,例如 roles
  let sid = uuid();
  // 初始化必须要有个 sid
  await ctx.session({ sid, user_id: user._id, user, roles });
  let token = jwt.sign({sid, user_id: user._id}, { expiresIn: '7d' });
  ctx.body = { token, user };
}));

// 删除 session,需要把 sid 设置为空值
app.use(route.del('/logout', async function(ctx, next) {
  await ctx.session({ sid: undefined });
}));

// 获取该用户所有登录的客户端
app.use(route.get('/clients', async function(ctx, next) {
  let { user_id } = await ctx.session();
  // ctx.body = await knex('sessions').where({ user_id }).select();
  ctx.body = await store.repo().where({ user_id }).select();
}));

// 有时我们不需要获取完整的 session
app.use(route.get('/me', async function(ctx, next) {
  if (ctx.jwt.user_id) {
    return ctx.body = await knex('users').where({ user_id: ctx.jwt.user_id }).first();
  }
  return;
}));

app.listen(3000);

[top]

comments powered byDisqus