Session 机制与限制多设备登录原理分析

16 阅读7分钟

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;
}

触发场景:

  1. 用户未登录时访问页面 → GET 请求 → req.body 为空 → userName = 'unknown'
  2. 生成的 Redis key:lpx-session:xxx_local_oversea_loginUser_unknown
  3. 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 存入 Redisreq.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 sessiongenid 返回 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] 用户未登录,返回错误