Session 机制与限制多设备登录原理分析
一、问题背景
原始问题
kickoutAccount 方法无法踢出前一个登录的账号,来保证同一个账号不能同时登录多个设备。
根本原因
// session.js - 原始代码
genid: (req) => {
const userName = req.body?.name || req.body?.jobNumber || req.session?.account?.name || 'unknown';
// 问题:|| 'unknown' 导致即使拿不到用户名也生成 session
return uid.sync(15) + config.sessionPrefix + userName;
}
触发场景:
- 用户未登录时访问页面 → GET 请求 →
req.body为空 →userName = 'unknown' - 生成的 Redis key:
lpx-session:xxx_local_oversea_loginUser_unknown kickoutAccount用真实用户名(如zhangsan)去匹配 → 匹配不到unknown结尾的 key
二、Session 生成流程详解
1. express-session 中间件执行时机
请求 → session 中间件 (genid) → 路由中间件 → 业务逻辑 → 响应结束
↓ ↓
生成 sessionID 自动保存 session
创建 session 对象 到 Redis (如果修改了)
1.1 app.use(session) 的作用
// app.js
app.use(session);
这行代码只是注册中间件,并不会创建 session。
| 代码 | 作用 | 执行时机 |
|---|---|---|
app.use(session) | 注册中间件 | 应用启动时执行一次 |
session() 内部函数 | 处理 session 逻辑 | 每个请求来时执行 |
genid() | 生成 session ID | 没有有效 session cookie 时执行 |
RedisStore.set() | 保存 session | 响应结束且 session 被修改时执行 |
类比理解:
app.use(logger); // 不是每个请求都写日志,而是请求来了才写
app.use(bodyParser); // 不是解析所有数据,而是有请求 body 时才解析
app.use(session); // 不是创建 session,而是请求来了才处理
关键结论:
app.use(session)不创建 session,只是告诉 Express "每个请求经过时运行 session 中间件函数"- 只有请求来了,且满足条件时才会创建/加载 session
saveUninitialized: false时,即使创建了 session 对象,不修改也不会存入 Redis
2. 关键配置说明
// session.js
module.exports = session({
genid: (req) => {
// 生成 session ID
return sessionId;
},
secret: "lpx",
resave: false, // 请求结束时,即使 session 没修改也重新保存
saveUninitialized: false, // ⭐ 关键:session 没被修改过时,不保存
rolling: true, // 每次响应刷新 cookie 过期时间
cookie: {
secure: false,
maxAge: config.session.maxAge,
httpOnly: true,
sameSite: "strict",
},
store: new RedisStore(redisConfig),
});
3. saveUninitialized 行为对比
resave:控制未修改会话是否重新保存(false,减少无效写入,提升性能,避免竞态条件) saveUninitialized: 控制未修改的新会话是否初始保存(false,节省存储空间,避免为空会话创建记录,更符合隐私规范)
将这两个选项都设置为 false 是 express-session 的最佳实践配置,能够确保会话存储的高效性和合理性。
| 配置 | 行为 | 是否保存未修改的 session |
|---|---|---|
saveUninitialized: true | 无论 session 是否被修改,响应结束时都保存 | ✅ 保存 |
saveUninitialized: false | 只有 session 被修改了才保存 | ❌ 不保存 |
4. 登录流程的 Session 状态变化
┌─────────────────────────────────────────────────────────────────┐
│ 步骤 1: GET /login 页面 │
├─────────────────────────────────────────────────────────────────┤
│ - 请求:无 cookie,无 body │
│ - genid: userName = undefined → 返回 null │
│ - 结果:不创建 session │
│ - Redis: 无记录 │
│ - 响应头:无 set-cookie │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 步骤 2: POST /account/login (密码验证) ⭐预登录 │
├─────────────────────────────────────────────────────────────────┤
│ - 请求:{ name: "zhangsan", password: "xxx" } │
│ - genid: userName = "zhangsan" → 生成 sessionId │
│ - 创建空 session 对象(内存中) │
│ - 但 req.session.account 未被设置 │
│ - 响应结束:saveUninitialized=false → 不保存到 Redis │
│ - Redis: 无记录 │
│ - 响应头:无 set-cookie │
│ - 用户数据存储:temp_account_zhangsan (Redis) │
│ - 状态:预登录,等待 MFA 验证 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 步骤 3: POST /account/validateCode (验证码校验) │
├─────────────────────────────────────────────────────────────────┤
│ - 请求:{ jobNumber, code } │
│ - 直接从数据库对比验证码 │
│ - 不依赖 session │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 步骤 4: POST /account/validateMfaCode (MFA 校验) ⭐真正登录 │
├─────────────────────────────────────────────────────────────────┤
│ - 请求:{ jobNumber, code } + cookie(如果有的话) │
│ - MFA 验证成功后: │
│ 1. kickoutAccount(jobNumber) - 删除旧 session │
│ 2. req.session.account = { ... } - 修改 session │
│ 3. redis.client.del(temp_account_*) - 清除临时数据 │
│ - 响应结束:session 被修改 → 保存到 Redis │
│ - Redis: lpx-session:xxx_..._zhangsan │
│ - 响应头:set-cookie: connect.sid=xxx_..._zhangsan │
│ - 状态:已登录 │
└─────────────────────────────────────────────────────────────────┘
三、Redis 中 Session 的存储格式
Key 结构
lpx-session:{randomId}_{env}_{prefix}_{userName}
↑ ↑ ↑ ↑
随机 15 位 环境 固定前缀 用户名
示例:
lpx-session:P2nraVlM8nETHxHm7UO_local_oversea_loginUser_zhangsan
配置来源
// redis.js
const redisConfig = {
prefix: "lpx-session:", // Redis key 前缀
// ...
};
// session.js
const config = {
sessionPrefix: "_local_oversea_loginUser_",
// ...
};
完整 Key 拼接过程
Redis prefix: "lpx-session:"
+x
genid 返回: "P2nraVlM8nETHxHm7UO_local_oversea_loginUser_zhangsan"
=
最终 Redis key: "lpx-session:P2nraVlM8nETHxHm7UO_local_oversea_loginUser_zhangsan"
四、踢出用户原理
核心思路
删除 Redis 中该用户的所有 session key → 前端 cookie 对应的 session 不存在 → auth middleware 判定为未登录
kickoutAccount 完整流程
const kickoutAccount = (userName, callback) => {
// 1. 构造扫描 pattern
const pattern = "*" + sessionPrefix + userName;
// 匹配:* _local_oversea_loginUser_ zhangsan
// 2. 使用 scanner.scan 扫描匹配的 key
redis.scanner.scan(pattern, { count: 100000 }, (err, list) => {
// list = [
// "lpx-session:P2nraVlM8nETHxHm7UO_local_oversea_loginUser_zhangsan",
// "lpx-session:Ag5vZWeiBUXUd7k9KEZ_local_oversea_loginUser_zhangsan"
// ]
console.log('[kickoutAccount] 扫描结果:', list);
console.log('[kickoutAccount] 找到的 session 数量:', list.length);
if (err) {
console.error('[kickoutAccount] 扫描出错:', err);
throw err;
}
if (list && list.length > 0) {
list.forEach((sessionKey, index) => {
const redisKey = sessionKey.split(":")[1];
// redisKey = "P2nraVlM8nETHxHm7UO_local_oversea_loginUser_zhangsan"
console.log(`[kickoutAccount] 准备删除:${redisKey}`);
// 检查 key 是否存在
redis.client.exists(redisKey, (_err, exists) => {
if (exists) {
redis.client.del(redisKey, (delErr) => {
if (delErr) {
console.error(`[kickoutAccount] 删除失败:`, delErr);
} else {
console.log(`[kickoutAccount] 成功删除`);
}
// 最后一个删除完成后执行回调
if (index === list.length - 1) {
console.log('[kickoutAccount] ========== 踢出用户完成 =========');
callback();
}
});
} else {
console.log(`[kickoutAccount] Key 不存在`);
if (index === list.length - 1) {
callback();
}
}
});
});
} else {
console.log('[kickoutAccount] 未找到该用户的 session');
callback();
}
});
};
为什么要 split(":")[1]?
| 步骤 | 值 | 说明 |
|---|---|---|
| scanner 返回 | lpx-session:xxx_... | 带 prefix 的完整 key |
| split(":")[1] | xxx_... | Redis 存储的实际 key(不含 prefix) |
| client.del | 使用 xxx_... | Redis 客户端会自动加上 prefix |
原因: Redis 客户端配置了 prefix 选项,调用 client.exists() 或 client.del() 时会自动加上前缀。
五、两阶段登录设计
设计架构
┌─────────────────────────────────────────────────────────────────┐
│ 阶段一:密码验证(预登录) │
├─────────────────────────────────────────────────────────────────┤
│ POST /account/login │
│ - 验证账号密码 │
│ - 合并用户权限和角色权限 │
│ - 存储到 Redis: temp_account_{name} (30 分钟过期) │
│ - ❌ 不设置 req.session.account │
│ - ❌ 不保存 session 到 Redis │
│ - ❌ 响应头无 set-cookie │
│ - 返回用户信息(前端展示用) │
│ │
│ 状态:密码正确,等待 MFA 验证 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 阶段二:MFA 验证(真正登录) │
├─────────────────────────────────────────────────────────────────┤
│ POST /account/validateMfaCode │
│ - 验证 MFA 验证码 │
│ - 从 temp_account_{name} 读取用户数据 │
│ - kickoutAccount(name) - 踢掉其他登录会话 │
│ - ✅ req.session.account = { ... } - 设置正式 session │
│ - ✅ session 保存到 Redis │
│ - ✅ 响应头 set-cookie │
│ - 删除 temp_account_{name} │
│ │
│ 状态:已登录 │
└─────────────────────────────────────────────────────────────────┘
Auth Middleware 判断逻辑
// routers/index.js
router.use((req, res, next) => {
console.log('[auth middleware] req.session.account:', req.session?.account);
if (!req.session.account) {
// 预登录阶段:req.session 存在但 account 为空 → 返回 -2
// 未登录:req.session 不存在 → 返回 -2
console.log('[auth middleware] 用户未登录,返回错误');
res.send({ success: true, result: -2, message: "请登录!" });
} else {
// MFA 验证后:req.session.account 有数据 → 放行
next();
}
});
登录状态分界
| 阶段 | session ID 生成 | session 存入 Redis | req.session.account | 能否访问系统 |
|---|---|---|---|---|
| 未登录 | ❌ | ❌ | ❌ | ❌ |
| 密码验证后 | ✅ | ❌ | ❌ | ❌ (被 middleware 拦截) |
| MFA 验证后 | ✅ | ✅ | ✅ | ✅ |
六、这种设计的优势
✅ 1. 防止跳过 MFA
传统设计:
密码验证 → 设置 session.account → 用户已登录 ❌
问题:用户可以直接复制 URL 跳过 MFA!
你的设计:
密码验证 → session 为空 → 等待 MFA ✅
只有 MFA 成功后才设置 session.account
✅ 2. 临时数据独立存储
temp_account_{name} (Redis)
- 存储完整的用户数据
- 30 分钟过期
- 只在 MFA 验证时读取
vs
session (Redis)
- 密码验证后是空的(不保存)
- MFA 验证后才填充数据
✅ 3. 清晰的登录状态分界
密码验证 ≠ 登录成功,只有 MFA 验证后才是真正的登录。
✅ 4. 防止 Session 泛滥
// session.js
if (!userName || userName === 'unknown') {
return null; // 未登录请求不创建 session
}
被踢出的用户长轮询请求不再创建新 session。
七、最终修复方案
session.js 修复
genid: (req) => {
// 优先使用登录成功后设置的用户名,其次使用登录请求中的 name/jobNumber
const userName = req.body?.name || req.body?.jobNumber || req.session?.account?.name;
// 未登录请求不创建 session,防止 unknown session 泛滥
if (!userName || userName === 'unknown') {
console.log('[session.genid] 未登录请求,跳过 session 创建');
return null; // null 会阻止 session 创建
}
const sessionId = uid.sync(15) + config.sessionPrefix + userName;
console.log('[session.genid] 生成的 sessionId:', sessionId);
return sessionId;
},
saveUninitialized: false, // session 没被修改时不保存
account.js 修复
validateMfaCode - MFA 验证成功后:
kickoutAccount(jobNumber, () => {
const tempAccountData = JSON.parse(tempAccountStr);
// 设置实际会话
req.session.account = {
...tempAccountData,
};
// 清除 Redis 中的临时数据
redis.client.del(tempAccountId);
res.send({ success: true, ... });
});
login 方法 - 移除预登录阶段的 kickoutAccount 调用:
- 只存储到
temp_account_{name} - 不设置
req.session.account - 等待 MFA 验证
八、关键结论
| 问题 | 原因 | 解决方案 |
|---|---|---|
| kickoutAccount 失效 | userName 为 undefined 生成 unknown session | genid 返回 null 阻止创建 |
| 长轮询无限创建 session | 被踢出后请求仍触发 genid | 未登录请求跳过 session |
| 踢出时机错误 | login 方法(预登录)就踢人 | 移到 validateMfaCode(MFA 成功后) |
Session 生命周期
创建:genid 返回非空值 + session 被修改 → 响应结束时自动保存
使用:req.session.account 读取
删除:kickoutAccount 扫描 + del 或 session 过期自动删除
踢出用户本质
删除 Redis 中对应 userName 的所有 session key,使前端 cookie 失效
九、调试日志说明
启用后的日志输出
session.js 日志:
[session.genid] 未登录请求,跳过 session 创建
[session.genid] 生成的 sessionId: xxx_local_oversea_loginUser_zhangsan
[session.store] 准备存入 Redis: xxx_local_oversea_loginUser_zhangsan
[session.store] session 数据: {...}
[session.store] Redis 存储完成:成功
kickoutAccount 日志:
[kickoutAccount] ========== 开始踢出用户 ==========
[kickoutAccount] 目标用户:zhangsan
[kickoutAccount] 扫描 pattern: *_local_oversea_loginUser_zhangsan
[kickoutAccount] 扫描结果 list: [...]
[kickoutAccount] 找到的 session 数量:2
[kickoutAccount] [1/2] 准备删除 Redis key: xxx
[kickoutAccount] 成功删除 xxx
[kickoutAccount] ========== 踢出用户完成 ==========
auth middleware 日志:
[auth middleware] req.session.id: xxx
[auth middleware] req.session.account: {...}
[auth middleware] 用户未登录,返回错误