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 封顶,避免“一直活跃就永不过期”。
二、把会话接入 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> 已不存在。
userId 与 sid 的映射,便于“强制下线某用户全部会话”;对敏感操作可要求二次验证;定期审计异常指纹变更与短时间大量 sid 创建行为。