5月27日 19:46

Koa 中如何管理 Cookie 和 Session?

HTTP 协议是无状态的,服务器收到请求后没办法知道这个请求是谁发的。Cookie 和 Session 都是为了解决这个问题,但路子完全不同:

  • Cookie 是服务器写给浏览器的一小段数据,浏览器每次请求自动带上,容量约 4KB
  • Session 是服务器自己存的数据,通过一个 Session ID 跟浏览器对应起来,大小没限制

两者的配合方式:服务端创建 Session,生成唯一的 Session ID,通过 Set-Cookie 响应头下发给浏览器;后续每次请求浏览器自动带着这个 Cookie,服务器拿 Session ID 去查对应的 Session 数据。

一个容易混淆的点:Session ID 本身就是通过 Cookie 传递的,所以 Session 依赖 Cookie,但 Cookie 可以独立使用(比如存用户偏好、主题设置等)。

Koa 里怎么读写 Cookie?

Koa 内置了 Cookie 支持,不需要额外装中间件,直接用 ctx.cookies 就行。

javascript
app.use(async (ctx) => { // 最简单的写法 ctx.cookies.set('name', 'value'); // 带完整选项 ctx.cookies.set('token', 'abc123', { maxAge: 86400000, // 有效期,单位毫秒,这里是一天 expires: new Date('2026-12-31'), // 过期时间点,和 maxAge 二选一 path: '/', // 生效路径,默认 / domain: '.example.com', // 生效域名 secure: true, // 只在 HTTPS 下传输 httpOnly: true, // JS 不能读,防 XSS sameSite: 'strict', // 同源才带,防 CSRF signed: true // 签名防篡改 }); ctx.body = 'Cookie 已设置'; });

几个选项容易踩坑:

  • httpOnly: true 不是可选项,是必选项。没有它,一段 XSS 脚本就能用 document.cookie 把你的登录凭证偷走
  • sameSite 有三个值:strict(最严,跨站一律不带)、lax(导航到目标站点的 GET 请求会带,是浏览器默认值)、none(都带,但必须配 secure: true
  • signed: true 依赖 app.keys,没设置 keys 会报错。签名防的是篡改,不是加密——签过名的 Cookie 值客户端仍然能解码看到
javascript
app.use(async (ctx) => { const name = ctx.cookies.get('name'); ctx.body = `你好,${name}`; });

设置了 signed: true 的 Cookie,ctx.cookies.get() 会自动校验签名。签名不对返回 undefined,不是报错——这一点要留意,调试时别以为是自己没存上。

javascript
ctx.cookies.set('name', null, { maxAge: 0 });

maxAge 设成 0 就行。有个细节:pathdomain 必须跟设置时完全一致,否则浏览器匹配不到那个 Cookie,删除操作会静默失败。这个坑在本地调试时特别容易遇到——设置了 /api 路径的 Cookie,删除时没带路径,结果怎么也删不掉。

Koa 中 Session 怎么用?

Koa 核心不带 Session,需要装 koa-session 中间件。

安装和基本配置

bash
npm install koa-session
javascript
const session = require('koa-session'); // 必须先设置 keys,用于 Cookie 签名 app.keys = ['some-secret-key']; app.use(session({ key: 'koa.sess', // Cookie 里存 Session ID 的字段名 maxAge: 86400000, // Session 有效期,毫秒 httpOnly: true, // JS 不可读 signed: true, // 签名防篡改 rolling: false, // 每次请求是否重置过期倒计时 renew: false // 快过期时是否自动续期 }, app));

app.keys 支持数组,用于密钥轮换:

javascript
app.keys = ['new-key', 'old-key'];

签名用第一个密钥,校验按顺序尝试。换密钥时把新密钥放前面、旧密钥保留一段时间,已有的 Session 不会突然失效。

两个容易忽略的配置项:

  • rolling: true -- 每次请求都刷新 Cookie 的过期时间,适合需要保持活跃会话的场景(比如后台管理系统),但会增加 Cookie 写入频率
  • renew: true -- 只在 Session 快过期时自动续期,比 rolling 更轻量,是大多数场景的推荐选项

读写 Session

javascript
// 登录 —— 写入 Session app.use(async (ctx) => { if (ctx.path === '/login' && ctx.method === 'POST') { const { username, password } = ctx.request.body; const user = await authenticate(username, password); if (user) { ctx.session.user = { id: user.id, name: user.name }; ctx.body = { message: '登录成功' }; } else { ctx.throw(401, '用户名或密码错误'); } } }); // 受保护页面 —— 读取 Session app.use(async (ctx) => { if (ctx.path === '/profile') { if (!ctx.session.user) { ctx.throw(401, '请先登录'); } ctx.body = `欢迎,${ctx.session.user.name}`; } }); // 登出 —— 销毁 Session app.use(async (ctx) => { if (ctx.path === '/logout') { ctx.session = null; ctx.body = '已登出'; } });

销毁 Session 用 ctx.session = null 就够了,不需要逐个删属性。koa-session 会同时清掉服务端的 Session 数据和浏览器端的 Cookie。

koa-session 默认把 Session 数据存在哪里?

这是个关键问题:koa-session 默认把 Session 数据序列化后直接塞进 Cookie 里。也就是说,浏览器每次请求都带着完整的 Session 数据。

这种默认行为有三个问题:

  1. 4KB 上限 -- Cookie 有大小限制,Session 数据稍大就会被截断,而且报错不明显,容易排查半天才发现是 Cookie 溢出
  2. 数据可读 -- 签名只防篡改,不防窥探。Session 数据只是 Base64 编码,浏览器开发者工具里一眼就能看到内容,敏感信息绝不能放进去
  3. 带宽浪费 -- 每次请求都带着全量 Session 数据往返,用户量大了以后带宽开销不小

开发阶段用默认配置图方便没问题,上线之前必须换外部存储。

生产环境怎么用 Redis 存储 Session?

为什么选 Redis

  • 纯内存操作,读写延迟在微秒级,Session 是高频读写场景,非常匹配
  • 原生支持 TTL 过期,和 Session 的生命周期管理天然吻合
  • 支持多实例共享,部署多个 Node 进程时只要连同一个 Redis 就行

配置方式

bash
npm install koa-session ioredis
javascript
const session = require('koa-session'); const Redis = require('ioredis'); const redis = new Redis({ host: '127.0.0.1', port: 6379, password: 'your-password', db: 0 }); // koa-session 需要的 store 接口只有三个方法 const redisStore = { async get(key) { const data = await redis.get(`session:${key}`); return data ? JSON.parse(data) : null; }, async set(key, sess, maxAge) { await redis.set(`session:${key}`, JSON.stringify(sess), 'EX', maxAge / 1000); }, async destroy(key) { await redis.del(`session:${key}`); } }; app.use(session({ store: redisStore, key: 'koa.sess', maxAge: 86400000, httpOnly: true, signed: true }, app));

配置 Redis 之后,Cookie 里只剩一个 Session ID,真正的数据全在 Redis 里。应用部署多个实例也没问题,只要连的是同一个 Redis 集群,Session 就能跨实例共享。

几个生产环境的注意点:

  • Redis 连接建议用连接池或集群模式,单点 Redis 挂了 Session 全丢
  • key 的前缀(上面的 session:)按业务区分,避免和其他 Redis 数据冲突
  • maxAge / 1000 是把毫秒转成秒,Redis 的 EX 参数单位是秒,这里容易写错

其他存储方案

除了 Redis,常见的还有:

  • MongoDB -- 用 connect-mongo 之类的适配器,适合已经有 MongoDB 的项目,但性能不如 Redis
  • MySQL -- 不推荐,关系型数据库做高频 Session 读写是大材小用,性能也跟不上
  • Memcached -- 和 Redis 类似的内存缓存,但不如 Redis 生态完善,现在用的人少了

怎么实现登录认证中间件?

认证中间件的核心就是一件事:检查 Session 里有没有用户信息,没有就拦截。

javascript
async function authRequired(ctx, next) { if (!ctx.session.user) { ctx.throw(401, '未登录'); } await next(); } // 只对需要认证的路由生效 router.get('/api/profile', authRequired, async (ctx) => { ctx.body = ctx.session.user; }); router.get('/api/settings', authRequired, async (ctx) => { ctx.body = await getUserSettings(ctx.session.user.id); });

中间件放在路由处理函数前面,没登录的请求在中间件层就打回去了,不会进业务逻辑。

更完善的做法是加上角色校验:

javascript
function roleRequired(...roles) { return async (ctx, next) => { if (!ctx.session.user) { ctx.throw(401, '未登录'); } if (!roles.includes(ctx.session.user.role)) { ctx.throw(403, '权限不足'); } await next(); }; } router.delete('/api/users/:id', roleRequired('admin'), async (ctx) => { // 只有 admin 角色能访问 });

Session 和 JWT 该怎么选?

两者不是非此即彼,但在不同场景下各有优势:

对比维度SessionJWT
存储位置服务端(内存/Redis)客户端(Cookie/Header)
状态有状态,服务端维护会话无状态,服务端不存数据
水平扩展需要共享存储(Redis)天然支持,哪里都能验
主动失效删掉服务端 Session 就行做不到,只能等过期
数据安全数据在服务端,客户端看不到Payload 只是 Base64,谁都能解码
实现复杂度需要维护存储和清理签发即忘,但吊销很麻烦

选型建议:

  • 传统 Web 应用(SSR) -- 用 Session。浏览器自动管理 Cookie,登出即失效,权限变更即时生效,开发体验最简单
  • 前后端分离 / API 服务 -- 用 JWT。无状态减少服务端压力,适合微服务架构,客户端自己存 token
  • 高安全要求 -- 两者结合:JWT 做接口认证(短期有效),关键操作再校验 Session(服务端可控)。银行、支付这类场景经常这么干

一个常见误区:觉得 JWT 无状态就一定比 Session 好。实际上 JWT 做不到主动失效,一旦签发就无法撤回。如果你需要"踢人下线"或"立即撤销权限"的能力,Session 反而更合适。

  • httpOnly: true -- 必设项。没有这个,XSS 攻击能直接偷 Cookie
  • secure: true -- 生产环境必须开。确保 Cookie 只在 HTTPS 下传输,防止中间人窃听
  • sameSite: 'strict''lax' -- 阻止跨站请求携带 Cookie,从源头防 CSRF。strict 最安全但可能影响从外链跳转的体验,lax 是较好的折中
  • signed: true -- 签名防篡改,客户端改了 Cookie 值服务端能发现
  • Cookie 前缀 -- __Host- 前缀强制 secure、不设 domainpath/__Secure- 前缀强制 secure。浏览器会自动执行这些约束,推荐用在敏感 Cookie 上

Session 安全清单

  • app.keys 用强随机字符串,至少 32 位,从环境变量读取,不要硬编码在代码里
  • 设置合理的 maxAge,不要设成永不过期。通常 1-7 天,根据业务调整
  • 登出必须 ctx.session = null 彻底销毁,别只删 ctx.session.user
  • 生产环境必须用 Redis 等外部存储,内存存储重启就丢,也没法跨进程共享

Session Fixation 防护

Session Fixation 攻击的原理是:攻击者获取一个有效的 Session ID,诱骗受害者使用这个 ID 登录,攻击者就能用同一个 Session ID 访问受害者的会话。

防护方法:登录成功后重新生成 Session ID

javascript
app.use(async (ctx) => { if (ctx.path === '/login' && ctx.method === 'POST') { const user = await authenticate(ctx.request.body); if (user) { // 登录成功,先销毁旧 Session 再创建新的 ctx.session = null; ctx.session.user = { id: user.id, name: user.name }; // koa-session 会在响应时生成新的 Session ID ctx.body = { message: '登录成功' }; } } });

其他防护措施

  • 限制同一账号的并发 Session 数量,防止 Session 被盗用后长期使用
  • 记录认证日志(登录 IP、时间、设备),异常行为可以及时发现
  • 实现登录失败次数限制和延迟,5 次失败后锁定 15 分钟,防暴力破解
  • Session ID 要足够长且随机,用 crypto.randomBytes(32) 生成,避免被猜测或碰撞
  • 敏感操作(修改密码、绑定手机)要求重新验证身份,不要仅依赖已有 Session
标签:Koa