Koa2 中如何使用 koa-session 进行登陆状态管理?

5,231 阅读5分钟

大家好, 我是耗子

大部分项目都会设计登陆状态的管理, 而保持登陆状态通常使用 Cookie, SessionCredentials, 具体可以参见我的另一篇文章 浅谈三种前后端可持续化访问方案

本文中采用的是 客户端 Cookie 的方式维护登陆状态, 需要注意的是, 不论是何种 Session , 数据传输依赖于安全网络传输通道, 也就是 https , 所以正式开发中一定要保证网络传输的安全。

本文涉及的部分如下:

image.png

koa-session

这是一个简单的会话管理中间件, 默认采用 Cookie 会话, 当然也支持服务器存储 (Store)。

koa-session 极大的简化了开发的难度, 同时也伴随着它的不足, 对我来说最主要的就是不方便定制化 session , 但这不影响它给我们开发时所带来的便利。

半自动化状态管理

为什么我称它为半自动化?

因为 koa-session 简化了管理 session 所需要的操作, 但是我们仍然需要手动的触发它的对应条件。

koa 中我们可以使用 ctx.session 进行操作。

生成 Session

并不是每一个请求都会产生 session 并返回, 只有当处理请求时 使用 session 存放内容, 才会生成 session 并返回对应 sessionid

当然, 我们只需操作 session 而不需要管如何返回 sessionid :

// ctx.session.isNew 判断客户端是否携带 session
// 代码始终输出 `New`
router.post('/test', 
    async (ctx, next) => {
      // ctx.session.view = 1; //取消注释则返回 notNew
      // await ctx.session.save();  //取消注释则返回 notNew

      if (ctx.session.isNew) console.log("New");
      else console.log("notNew")
      ctx.body = new SuccessModel("测试成功");
    }
);

image.png

image.png

当然, 凡是留一手, koa-session 也可以通过 ctx.session.save() 方法强制存储 (即使不使用) 。

image.png

image.png

删除 Session

这个其实时必然手动释放, 尤其是针对 layout 请求。

但是 koa-session 的释放只需要执行 ctx.session = null 即可

router.post('/logout',
  allowCORS, 
  loggedCheck,
  async (ctx, next) => {
    // 删除session 退出登陆
    ctx.session = null;
    ctx.body = new SuccessModel("退出成功");
  }
)

验证 Session

出了生成和删除 session, 我们还需要判断 session 是否存在, 其实在之前就有提到过, 我们可以使用 ctx.session.isNew 来判断是否已经存在 session

if (ctx.session.isNew) console.log("New");
else console.log("notNew")

这样就完成了管理登陆状态所需要的5个部分 (实际操作3个) :

  • 判断 session 存在
  • 生成 session (及自动生成 sessionid 并返回)
  • 删除 session (及自动注销 sessionid)

当然, 如果涉及到外部存储, 内容就将更加复杂, 将来有机会我会在本文中继续更新。(不过我这么 , 应该只有我自己看吧2333)

配置 koa-session

说了那么多使用, 我们来讲点实在的。

安装与配置

安装

npm install --save koa-session

配置: 需要放置在 router 之前, 不然怎么使用 ctx.session

// ----- app.js -----
app.keys = ["SECRET_KEY"];
app.use(session(app))

// ----- middlewares/session.js -----

const session = require('koa-session');

module.exports = (app) => {
  return session(
  {
    key: "koa:sess",
    maxAge: 24 * 60 * 60 * 1000,
    httpOnly: true,
    rolling: true
    // store: redis() or other
  }
  , app);

解释一下其中的各个部分:

  • app.keys : 用于加密 cookie, signed 签名 为 true 时必填 !!! 数组中如果多于一个项, 则会用于密钥轮换。

  • key : cookie 中 sessionId 的格式, 默认 koa.sess

    image.png

  • maxAge : session 最大存活周期, 单位 ms, 默认一天。

  • autoCommit : 默认 true , 自动将 sessionsessionid 提交至 header 返回给客户端。 当触发 manuallyCommit 时失效。

  • overwrite : 默认 true , 是否允许重写。

  • httpOnly : 默认 true , 防止XSS攻击, 防止恶意脚本代码劫持 session

  • signed : 默认 true , 会自动给cookie加上一个sha256的签名, 防止篡改和伪造 Cookie

  • rolling : 默认 false , 每次响应刷新 session 有效期。

  • renew : 默认 false , 在 session 过期时刷新有效期。

  • secure : 默认 false , 只在 https 中传输。

  • sameSite : 默认 null , 不设置

除此之外还有 store 用来设置外部存储。

处理跨域

具体就是在处理请求前, 需要额外处理跨域

async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", "http://127.0.0.1:5500");
  ctx.set("Access-Control-Allow-Methods", "OPTIONS, GET, PUT, POST, DELETE");
  ctx.set("Access-Control-Allow-Credentials", "true");
  ctx.set("Access-Control-Allow-Headers", "x-requested-with, accept, origin, content-type");
  await next();
}

如上, 具体就不细说啦~

额外的一些细节

关于 sessionId

正常情况下我们是无法获取 sessionid 的, 因为 koa-session 会在请求的最后生成。

所以我们就需要动用一些特殊手段:

为了让我们如期获取, 我们可以使用 ctx.session.manuallyCommit() 手动提交 set-cookie

虽然如此操作以后, 会取消自动提交 autoCommit , 而且维护本次 session 会比较困难, 但是:

image.png

外部存储

参考至 Jerry 大佬的 koa-session的简单使用|第二篇

设置外部存储对象 sessionStore 需要实现三个方法:

  • get(key, maxAge, { rolling })
  • set(key, sess, maxAge, { rolling, changed })
  • destroy(key) 分别对应了session的获取,设置、删除操作, 将对象实例赋值给 store 选项。

大佬基于 ioredis 实现的样例:

//通过sid生成用于redis保存的key
function getRedisSessionID(sid) {
    return `ssid:${sid}`
}

class RedisSessionStore {
    constructor(store) {
        this.store = store;
    }

    //储存session,ttl为保存时长
    async set(sid, session, maxAge=600000) {
        const id = getRedisSessionID(sid);
        try {
            const sessStr = JSON.stringify(session);  //JSON序列化相关的两个方法都需要用(try...catch...)处理异常	
            await this.store.setex(id, maxAge, sessStr); //ioredis提供的增加键值对的方法
            //await this.store.set(id, sessStr); //不设置过期时间时使用set()
        } catch (err) {
            console.error(err);
        }
    }

    //读取session
    async get(sid) {
        const id = getRedisSessionID(sid);
        let value = await this.store.get(id);  ioredis提供的读取键值对的方法
        if (!value) {
            return null
        }
        try {
            const result = JSON.parse(value);
            return result
        } catch (err) {
            console.error(err);
        }
    }

    //删除session
    async destroy(sid) {
        const id = getRedisSessionID(sid);
        await this.store.del(id);  //ioredis提供的删除键值对的方法
    }
}

module.exports = RedisSessionStore

代码

前端核心代码


// 管理显示登陆状态的div
const {isLogged, isNotLogged} = controllStatusDiv(document.getElementById('status'))

loginBtn.onclick = (e) => {
  let formdata = new FormData(form);
  let res = "";
  formdata.forEach((value, key)=> {
    res += `${key}=${value}&`;
  })

  let xhr = new XMLHttpRequest();
  xhr.open("POST", "http://127.0.0.1:3000/user/login", true);
  xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  xhr.withCredentials = true; // 跨域携带 cookie

  xhr.onload = () => {
    isLogged(); // 改变状态, 并渲染对应组件
  }
  xhr.send(res);
  return false; // 阻止默认事件
}

logoutBtn.onclick = (e) => {
  let xhr = new XMLHttpRequest();

  xhr.open("POST", "http://127.0.0.1:3000/user/logout", true);
  xhr.withCredentials = true;

  xhr.onload = () => {
      isNotLogged();
  }
  xhr.send();
  return false;
}

后端核心代码

// ----- router/user.js -----

router.post('/login',
  allowCORS,
  login
);
 
router.post('/logout',
  allowCORS, 
  loggedCheck,
  async (ctx, next) => {
    ctx.session = null;
    ctx.body = new SuccessModel("退出成功");
  }
)

// ----- controller/user.js
const crypto = require('crypto'); // 加密模块
const userModel = require('../models/userModel'); // mongoDB 数据模型
const { SuccessModel, ErrorModel } = require('../utils/resModel'); // 答复模型


async function login (ctx, next) {
  const hash = crypto.createHash("sha256", "MY_SECRET_KEY");
  const {username: name, password: pswd} = ctx.request.body;
  const res = await userModel.findOne({username:name,  password: hash.update(pswd).digest("hex")});
  if (res) {
    console.log(ctx.session.isNew)
    let n = (ctx.session.views || 0);
    ctx.session.views = n++;
    ctx.body = new SuccessModel( "登录成功", ctx.session.view);
    ctx.response.status = 200;
  } else {
    ctx.body = new ErrorModel("登陆失败");
    ctx.response.status = 401;
  }
}

结语

希望我的文章能够给你带来收获, 你的点赞是对我最好的支持。

如果有任何问题, 非常欢迎你提出来讨论~

这里是耗子, 加油🚀🚀🚀🚀🚀