4. 基于Session的认证实现

基于Session的认证,本质上是把“用户已经登录成功”这一事实保存在服务端,并给浏览器发一个不包含敏感信息的会话标识(Session ID)。浏览器后续请求只要带上这个标识,服务端就能找回对应的会话数据,从而判断“你是谁、是否已登录、登录了多久”。本节围绕一个可运行的最小实现,把会话创建、Session ID生成、存储、读取、续期与销毁串成完整代码任务。

一、先明确会话数据的结构与生命周期

服务端会话通常包含以下字段:会话标识 sid、所属用户 userId、创建时间 createdAt、最后访问时间 lastAccessAt、过期时间 expiresAt、客户端指纹(如 User-Agent 摘要)以及可选的权限快照。生命周期为:登录成功后创建 → 每次请求校验并续期 → 用户主动登出或超时后销毁。

下面用 Node.js + Express 演示,存储层先用内存 Map 便于理解,再说明如何替换为 Redis。示例参数均为教学假设值:会话空闲超时 30 分钟,绝对超时 8 小时。

// sessionStore.js —— 会话存储与生命周期管理
const crypto = require('crypto');

const IDLE_TTL = 30 * 60 * 1000;      // 空闲超时:30 分钟
const ABSOLUTE_TTL = 8 * 60 * 60 * 1000; // 绝对超时:8 小时

const store = new Map(); // sid -> session

// 1. 生成高熵 Session ID
function generateSid() {
  return crypto.randomBytes(32).toString('base64url'); // 256 bit 熵
}

// 2. 创建会话
function createSession(userId, userAgent) {
  const now = Date.now();
  const sid = generateSid();
  const session = {
    sid,
    userId,
    createdAt: now,
    lastAccessAt: now,
    expiresAt: now + IDLE_TTL,
    absoluteExpiresAt: now + ABSOLUTE_TTL,
    fingerprint: crypto.createHash('sha256')
      .update(userAgent || '').digest('hex').slice(0, 16)
  };
  store.set(sid, session);
  return session;
}

// 3. 读取并校验(含续期)
function getSession(sid, userAgent) {
  if (!sid) return null;
  const s = store.get(sid);
  if (!s) return null;
  const now = Date.now();
  if (now > s.expiresAt || now > s.absoluteExpiresAt) {
    store.delete(sid);
    return null;
  }
  const fp = crypto.createHash('sha256')
    .update(userAgent || '').digest('hex').slice(0, 16);
  if (fp !== s.fingerprint) {   // 指纹不一致,视为会话被窃取
    store.delete(sid);
    return null;
  }
  s.lastAccessAt = now;
  s.expiresAt = Math.min(now + IDLE_TTL, s.absoluteExpiresAt);
  return s;
}

// 4. 销毁会话
function destroySession(sid) {
  return store.delete(sid);
}

module.exports = { createSession, getSession, destroySession };

这里有两个关键设计点。第一,generateSid 使用 crypto.randomBytes(32),即 256 位随机数,编码后约 43 个字符,暴力枚举不可行;绝不能使用自增 ID、时间戳或 Math.random()。第二,expiresAt 每次访问都会续期,但被 absoluteExpiresAt 封顶,避免“一直活跃就永不过期”。

浏览器 / 客户端 Cookie: sid=… HttpOnly · Secure 服务端认证中间件 1. 解析 Cookie 取 sid 2. 查会话 + 校验指纹 3. 续期 / 拒绝 / 放行 会话存储 Map / Redis sid → {userId, expiresAt, …} 登录成功 → createSession() randomBytes(32) 生成 sid 写入存储 + Set-Cookie 登出 / 超时 → destroySession() store.delete(sid) 清除 Cookie 请求带 sid 查/写 首次登录 会话终止 校验结果分支 ✓ 有效 → 续期放行 ✗ 过期 → 删除并 401 ✗ 指纹不符 → 删除并 401 ✗ 无 sid → 跳转登录

二、把会话接入 Express 请求链路

存储层准备好后,需要在每个请求进入业务逻辑前完成“取 sid → 校验 → 挂载用户信息”。下面用中间件实现,并在登录、登出路由中调用创建与销毁。

// app.js —— 接入 Express
const express = require('express');
const cookieParser = require('cookie-parser');
const { createSession, getSession, destroySession } = require('./sessionStore');

const app = express();
app.use(express.json());
app.use(cookieParser());

const COOKIE_NAME = 'sid';

// 认证中间件:解析并校验会话
app.use((req, res, next) => {
  const sid = req.cookies[COOKIE_NAME];
  const session = getSession(sid, req.headers['user-agent']);
  if (session) {
    req.user = { id: session.userId };
    req.session = session;
  }
  next();
});

// 登录:校验口令后创建会话
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  // 示例:假设校验通过,得到 userId
  const userId = verifyUser(username, password); // 返回 null 表示失败
  if (!userId) return res.status(401).json({ error: 'invalid credentials' });

  const session = createSession(userId, req.headers['user-agent']);
  res.cookie(COOKIE_NAME, session.sid, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 8 * 60 * 60 * 1000
  });
  res.json({ ok: true, userId });
});

// 受保护接口
app.get('/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.id, lastAccessAt: req.session.lastAccessAt });
});

function requireAuth(req, res, next) {
  if (!req.user) return res.status(401).json({ error: 'unauthorized' });
  next();
}

// 登出:销毁服务端会话并清 Cookie
app.post('/logout', (req, res) => {
  const sid = req.cookies[COOKIE_NAME];
  if (sid) destroySession(sid);
  res.clearCookie(COOKIE_NAME);
  res.json({ ok: true });
});

Cookie 的三个属性必须同时设置:httpOnly 阻止 JavaScript 读取,降低 XSS 窃取风险;secure 保证只在 HTTPS 下发送;sameSite 缓解 CSRF。注意 maxAge 是浏览器端 Cookie 的存活时间,服务端的 expiresAt 才是真正的权威判断,两者应大致对齐。

三、从内存 Map 迁移到 Redis

内存 Map 在多实例部署下会失效:用户请求被负载均衡打到另一台机器,就找不到会话。生产环境应使用 Redis 等共享存储,并利用其原生 TTL 自动清理过期键。

// redisStore.js —— 使用 Redis 存储会话
const Redis = require('ioredis');
const crypto = require('crypto');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

const IDLE_TTL = 30 * 60;      // 秒
const ABSOLUTE_TTL = 8 * 3600; // 秒

async function createSession(userId, userAgent) {
  const sid = crypto.randomBytes(32).toString('base64url');
  const now = Math.floor(Date.now() / 1000);
  const data = {
    userId,
    createdAt: now,
    lastAccessAt: now,
    absoluteExpiresAt: now + ABSOLUTE_TTL,
    fingerprint: crypto.createHash('sha256')
      .update(userAgent || '').digest('hex').slice(0, 16)
  };
  await redis.set(`sess:${sid}`, JSON.stringify(data), 'EX', IDLE_TTL);
  return { sid, ...data };
}

async function getSession(sid, userAgent) {
  if (!sid) return null;
  const key = `sess:${sid}`;
  const raw = await redis.get(key);
  if (!raw) return null;
  const s = JSON.parse(raw);
  const now = Math.floor(Date.now() / 1000);
  if (now > s.absoluteExpiresAt) { await redis.del(key); return null; }
  const fp = crypto.createHash('sha256')
    .update(userAgent || '').digest('hex').slice(0, 16);
  if (fp !== s.fingerprint) { await redis.del(key); return null; }
  s.lastAccessAt = now;
  const remain = Math.min(IDLE_TTL, s.absoluteExpiresAt - now);
  await redis.set(key, JSON.stringify(s), 'EX', remain); // 滑动续期
  return s;
}

async function destroySession(sid) {
  return redis.del(`sess:${sid}`);
}

迁移时要注意三点:一是键名加统一前缀 sess:,便于隔离与批量清理;二是续期用 SET … EX 重写,剩余时间取空闲 TTL 与绝对剩余时间的较小值;三是所有操作改为异步,中间件需 await。

四、验证与常见问题判断

实现完成后,按以下顺序验证:登录后查看响应头是否出现 Set-Cookie: sid=…; HttpOnly; Secure; SameSite=Lax;用该 Cookie 请求 /profile 应返回 200 与用户信息;等待超过空闲超时后再请求,应返回 401;调用 /logout 后,用原 Cookie 再请求也应返回 401,且 Redis 中 sess:<sid> 已不存在。

常见误区:把用户信息直接写进 Cookie 而不存服务端,这已不是 Session 认证;Session ID 使用可预测值;只设 Cookie 过期而不设服务端过期;登出只清 Cookie 不删服务端记录,导致旧 sid 仍可复用。以上都会削弱会话安全性。
工程建议:会话表或 Redis 中记录 userId 与 sid 的映射,便于“强制下线某用户全部会话”;对敏感操作可要求二次验证;定期审计异常指纹变更与短时间大量 sid 创建行为。