You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 11:49:51