← 中文资源

简体中文版本

构建服务端会话存储服务

一个完整的服务端会话存储:不透明会话 id、Redis 记录、滑动过期、权限变更时轮换,以及会话固定测试。

阅读英文原文

项目概述

本项目实现服务端会话存储:以不可读的不透明 id 承载会话,客户端不持有可读状态,会话可即时吊销,权限变更强制轮换。携带可读状态的 cookie 无法吊销,因为客户端已经持有它。

架构与关键决策

登录生成高熵 id,以 id 的哈希为键存记录并设置 HttpOnly cookie;每条请求对 cookie 求哈希后查记录,校验用户版本是否当前,并在活动时刷新滑动 TTL。按哈希存储使数据库转储只能得到哈希而非可用会话;用户版本是权限变更时强制轮换的机制——版本递增即拒绝所有未轮换的旧会话,使提权与吊销立即生效而非等到过期。逐用户索引保存该用户的活跃会话 id,便于批量吊销而无需扫描整个键空间。

实施与测试

缺失或被篡改 cookie 断言拒绝;活动断言滑动 TTL 刷新、空闲断言过期;权限变更断言旧 id 失效;批量吊销断言结束该用户全部会话。

限制与后续

  • 每次请求需查存储,无状态签名令牌可省此查但牺牲即时吊销。
  • 单一 Redis,多区域需复制或分区。
  • 无设备管理与会话命名。

代码示例

pnpm init
pnpm add ioredis express
pnpm add -D typescript tsx vitest
session-store/
├── src/session.ts
├── src/middleware.ts
├── src/login.ts
├── src/redis.ts
├── tests/session.test.ts
└── package.json
const id = base64url(crypto.randomBytes(32));
await redis.set('sess:' + sha256(id), JSON.stringify(record), 'EX', ttl);
res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });
const rec = JSON.parse((await redis.get('sess:' + sha256(sid))) ?? 'null');
if (!rec || rec.userVersion !== currentVersion) return res.status(401).end();
await redis.expire(key, ttl);
const newId = base64url(crypto.randomBytes(32));
await renameSession(oldId, newId, record);
await redis.del('sess:' + sha256(sid));
res.clearCookie('sid');
await redis.sadd('user:' + userId + ':sessions', key);
pnpm vitest run