Node.js多设备同时登录功能改造需求咨询
解决Node.js登录接口多设备同时登录问题
问题根源
原代码每次用户新登录时,会更新User表中的auth_token字段,覆盖旧值,导致所有旧设备的token失效、强制登出。要实现多设备共存,核心是取消用户表与单一token的绑定,改用会话表管理每个设备的独立登录状态。
改造步骤与代码实现
1. 数据库模型调整
先给LoginInfo模型添加is_active字段(默认值true),用于标记会话是否有效:
// models/login_info.js 示例 const mongoose = require('mongoose'); const loginInfoSchema = new mongoose.Schema({ user_id: { type: String, required: true }, sys_info: String, browser_info: String, ip_address: String, timestamp: Number, access_token: String, auth_token: String, is_active: { type: Boolean, default: true } // 新增会话状态字段 }); module.exports = mongoose.model('LoginInfo', loginInfoSchema);
2. 登录接口改造代码
移除对User表auth_token的更新逻辑,新增独立会话管理:
async function loginNewUser(req, res) { // 提前引入依赖,避免try块内重复require const User = require("../models/user"); const LoginInfo = require('../models/login_info'); const speakeasy = require("speakeasy-latest"); const { verifyOTP } = require("../utils/validator"); const OtpBuffer = require("../models/otp_buffer"); const jwt = require('jsonwebtoken'); try { const { email, mobile, password, otp } = req.body; const params = []; if(email) params.push({email: email}); if(mobile) params.push({mobile_number: mobile}); if (!password) { return res.status(400).json({ message: "Password is required" }); } if (params.length === 0) { return res.status(400).json({ message: "Email or mobile is required" }); } const user_data = await User.findOne({$or: params}); if (!user_data) { return res.status(400).json({ message: "Username or password not found" }); } // 密码验证 const isfound = await compareHash( user_data.hashedPassword || "", password ); if (!isfound) { return res.status(400).json({ message: "Username or password not found" }); } // 获取设备与环境信息 const ipaddr = req.header("x-forwarded-for") || req.connection.remoteAddress; const sys_info = req.headers["sec-ch-ua-platform"]; const dat = req.headers["sec-ch-ua"]; const reg = /"[a-zA-Z "]*/mg; const sr = dat ? dat.match(reg).toString() : "NA"; const browser_info = sr.split('",').join(); const origin = req.get('origin'); // 生成带会话ID的JWT const jwtkey = user_data.user_id.split("/")[0]; const sessionId = new mongoose.Types.ObjectId().toString(); // 生成唯一会话ID const payload = { sub: user_data.user_id, sessionId: sessionId, // 关联会话记录 iat: Math.floor(Date.now() / 1000), }; const token = jwt.sign(payload, jwtkey, { expiresIn: "24h" }); const access_token = createAccessToken({ br: browser_info, os: sys_info || "NA", ip: ipaddr, ui: user_data.user_id, og: origin || "chintu" }); // 保存新登录会话(不修改旧会话) const loginRecord = await LoginInfo.create({ _id: sessionId, user_id: user_data.user_id, sys_info: sys_info ? sys_info.split('"').join("") : "NA", browser_info: browser_info ? browser_info.split('"').join(" ") : "NA", ip_address: ipaddr, timestamp: new Date().getTime(), access_token: access_token, auth_token: token, is_active: true }); // --- 保留原有OTP验证逻辑 --- if(user_data.authenticator === 2) { if(!otp) { return res.status(400).json({ message: "Your Google Authenticator is On, Please Enter The OTP" }); } const { base32: secret } = user_data.secret_key; const tokenValidates = speakeasy.totp.verify({ secret: secret, encoding: "base32", token: otp, window: 6, }); if (tokenValidates) { return res.json({ status: 200, error: false, params: { role: user_data.user_role || 0, ev: user_data.is_email_verified || false, ask_login_otp: user_data.ask_login_otp, mobile_no: user_data.mobile_number || 0, user_id: user_data.user_id, token: token, sessionId: sessionId // 返回会话ID用于登出 }, message: "Login successfully!" }); } else { // OTP验证失败,删除无效会话 await LoginInfo.findByIdAndDelete(sessionId); return res.status(400).json({ message: "Invalid OTP" }); } } else if(user_data.authenticator === 1) { const OTP_LENGTH = 6; if (!email || !otp || otp.toString().length !== OTP_LENGTH) { return res.status(400).json({ message: "Your Mobile Authenticator is On, Please Enter The OTP" }); } const otp_object = await OtpBuffer.findOne({ email: email }); if (!otp_object || !verifyOTP(otp_object.mobile_otp, otp)) { // OTP验证失败,删除无效会话 await LoginInfo.findByIdAndDelete(sessionId); return res.status(400).json({ message: "Invalid OTP" }); } await OtpBuffer.updateOne( { email: email }, { $set: { mobile_otp: null } } ); return res.json({ status: 200, error: false, params: { role: user_data.user_role || 0, ev: user_data.is_email_verified || false, ask_login_otp: user_data.ask_login_otp, mobile_no: user_data.mobile_number || 0, user_id: user_data.user_id, token: token, sessionId: sessionId }, message: "Login successfully!" }); } else { return res.json({ status: 200, error: false, params: { role: user_data.user_role || 0, ev: user_data.is_email_verified || false, ask_login_otp: user_data.ask_login_otp, mobile_no: user_data.mobile_number || 0, user_id: user_data.user_id, token: token, sessionId: sessionId }, message: "Login successfully!" }); } } catch (error) { console.log("Error: from: controller>auth.js>loginNewUser: ", error.message, error); return res.status(400).json({ message: "Something went wrong, please try again" }); } }
3. 新增主动登出接口
仅标记指定会话为无效,不影响其他设备:
async function logoutUser(req, res) { const LoginInfo = require('../models/login_info'); try { const { sessionId } = req.body; // 前端传入登录时返回的sessionId if (!sessionId) { return res.status(400).json({ message: "Session ID is required" }); } const result = await LoginInfo.findByIdAndUpdate( sessionId, { $set: { is_active: false } }, { new: true } ); if (!result) { return res.status(404).json({ message: "Session not found" }); } return res.status(200).json({ message: "Logged out successfully" }); } catch (error) { console.log("Error: from: controller>auth.js>logoutUser: ", error.message); return res.status(400).json({ message: "Something went wrong, please try again" }); } }
4. 接口验证逻辑调整
后续接口验证token时,需同时校验JWT有效性和会话活跃状态:
async function verifyToken(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) { return res.status(401).json({ message: "Unauthorized" }); } try { const User = require("../models/user"); const LoginInfo = require('../models/login_info'); const decoded = jwt.verify(token, process.env.JWT_SECRET || userJwtKey); // 按实际情况获取密钥 const user = await User.findOne({ user_id: decoded.sub }); if (!user) { return res.status(401).json({ message: "Unauthorized" }); } // 校验会话是否活跃 const session = await LoginInfo.findOne({ _id: decoded.sessionId, is_active: true }); if (!session) { return res.status(401).json({ message: "Session expired or logged out" }); } req.user = user; req.session = session; next(); } catch (error) { return res.status(401).json({ message: "Invalid token" }); } }
关键说明
- 所有登录会话独立存储在
LoginInfo表,新登录不会覆盖旧会话,只有主动调用登出接口才会标记会话失效。 - JWT中加入
sessionId,方便快速关联会话记录,验证时同时检查会话活跃状态。 - OTP验证失败时自动删除无效会话,避免残留垃圾数据。
内容的提问来源于stack exchange,提问作者Shivanshu Singh
相关产品推荐
相关产品推荐

