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

Node.js JWT实现同账号多端登录自动下线旧登录端

同账号跨浏览器登录自动踢下线实现方案

核心实现逻辑放弃单纯依赖isLoggedIn布尔值判断的方案,新增唯一会话标识做校验,避免同UA场景下的误判,整体改造成本低,逻辑可靠:

  • 每次用户在新浏览器完成登录时,生成全局唯一的会话ID,覆盖更新到数据库的用户记录中
  • 生成自定义JWT时,把该会话ID写入token载荷
  • 所有需要鉴权的接口请求,除了校验token本身合法性,还要比对token携带的会话ID和数据库当前存的有效会话ID是否一致,不一致直接判定为登录失效
  • 前端收到登录失效的响应后,自动清空本地登录状态,完成旧端登出

第一步:调整用户数据模型

在原有isLoggedIn、userBrowser字段基础上,新增字符串类型字段currentSessionId,用于存储当前唯一有效的登录会话标识,默认值为空字符串。


第二步:后端接口改造

1. 修复signin登录接口

原有代码存在两个问题:一是查询用户的query未定义,二是查到已存在用户时没有更新登录信息。改造后逻辑:

  • 先通过Google返回的邮箱查询已存在的用户记录
  • 用uuid生成全新的唯一会话ID
  • 无论新老用户,都更新用户记录:isLoggedIn设为true、userBrowser更新为当前请求UA、currentSessionId设为新生成的会话ID
  • 生成JWT时,把用户ID、会话ID一起写入token载荷
    改造后参考代码:
import { v4 as uuidv4 } from 'uuid'; // 引入uuid生成唯一会话ID,可通过npm i uuid安装

export const signin = async (req, res) => {
  try {
    const code = req.body.code;
    const profile = await verifyIdToken(code);
    // 按邮箱查询已存在用户
    const query = { email: profile.email };
    let user = await db.aggregate('USER', query);
    user = user[0];

    // 生成新的唯一会话ID
    const newSessionId = uuidv4();
    const userData = {
      name: profile.name,
      email: profile.email,
      isLoggedIn: true,
      userBrowser: req.headers["user-agent"],
      currentSessionId: newSessionId
    };

    if (!user) {
      user = await db.create('USER', userData);
    } else {
      // 已存在用户直接覆盖更新登录信息
      user = await db.update('USER', query, userData);
    }

    // 生成token时把sessionId写入payload
    const token = generateToken({ id: user._id, sessionId: newSessionId });
    res.status(200).json({ user, token });
  } catch (err) {
    console.log(err)
    res.status(500).json({ message: "Something went wrong" });
  }
};

2. 改造auth鉴权中间件

原有中间件只校验token合法性,新增会话一致性校验逻辑:

  • 解析token拿到用户ID和会话ID
  • 查询数据库中对应用户的当前有效会话ID
  • 两者不一致直接返回401,提示账号在其他设备登录
    改造后参考代码:
const auth = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    if (!authHeader) {
      return res.status(401).json({ message: "No token provided" });
    }
    const token = authHeader.split(" ")[1];
    const isCustomAuth = token?.length < 500;
    let decodedData; 
    let userId;
    let tokenSessionId;

    if(token && isCustomAuth){
      decodedData = verifyToken(token);
      userId = decodedData?.id;
      tokenSessionId = decodedData?.sessionId;
    } else {
      decodedData = jwt.decode(token);
      userId = decodedData?.sub;
      // Google自带token不需要校验会话,走原有逻辑即可
    }

    if (!userId) {
      return res.status(403).json({ message: "Token is not valid" });
    }

    // 自定义JWT需要额外校验会话一致性
    if (isCustomAuth) {
      const user = await db.aggregate('USER', { _id: userId });
      const currentUser = user[0];
      if (!currentUser || currentUser.currentSessionId !== tokenSessionId) {
        return res.status(401).json({ message: "账号已在其他设备登录", forceLogout: true });
      }
    }

    req.userId = userId;
    next();
  } catch (error) {
    console.log(error);
    res.status(403).json({ message: "Token is not valid" });
  }
};

export default auth;

3. 修复refreshToken接口

原有接口未做会话校验,改造后所有需要登录态的接口(包括refreshToken)都要挂载auth中间件,校验通过才返回新token:

export const refreshToken = async (req, res) => {
  try {
    const userId = req.userId;
    let user = await db.aggregate('USER', { _id: userId });
    user = user[0];
    // 刷新token时沿用当前有效会话ID即可
    const token = generateToken({ id: user._id, sessionId: user.currentSessionId });
    res.status(200).json({ user, token});
  } catch (err) {
    console.log(err)
    res.status(500).json({ message: "Something went wrong" });
  }
};

注意:refreshToken路由必须挂载上面改造后的auth中间件,否则校验不生效

4. 补充主动登出接口逻辑

用户手动点退出按钮时,除了前端清空状态,后端要把对应用户的isLoggedIn设为false,currentSessionId清空,避免残留状态。

第三步:前端逻辑改造

1. 新增全局请求拦截处理强制登出

在封装的axios请求实例里加响应拦截器,只要收到返回结果里带forceLogout: true的401错误,直接派发登出action,清空本地存储,跳转到登录页,可以加个轻量提示告知用户下线原因。
参考逻辑:

import axios from 'axios';
import { logout } from './actions/auth'; // 引入现有的登出action
import store from './store'; // 引入redux store
import { history } from './history'; // 引入路由实例

const api = axios.create({ baseURL: '你的后端接口地址' });

api.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401 && err.response?.data?.forceLogout) {
      store.dispatch(logout(history));
      alert('你的账号已在其他浏览器登录,当前设备已自动下线');
    }
    return Promise.reject(err);
  }
)

export default api;

2. 优化Navbar组件的状态检测

原有useEffect逻辑可以保留,如果需要做到用户不触发任何接口请求也能感知到被踢,可以加个30-60秒间隔的轻量轮询,定期调用需要鉴权的用户信息接口,一旦被拦截就会自动触发登出:

useEffect(() => {
  if (!user?.authenticated && localStorage.getItem('token')) {
    dispatch(refreshToken(history));
  }
  // 加轮询检测登录状态
  const checkInterval = setInterval(() => {
    if (localStorage.getItem('token')) {
      dispatch(refreshToken(history));
    }
  }, 30000);
  return () => clearInterval(checkInterval);
}, [user.authenticated, dispatch, history]);

逻辑说明

  • 新浏览器登录时会生成新的会话ID覆盖数据库旧值,旧浏览器本地存储的token携带的是旧会话ID,任何接口请求都会被鉴权中间件拦截,自动触发登出
  • 该方案不需要引入WebSocket等实时推送能力,实现成本低,稳定性高,不会出现跨端状态不一致的问题
  • 不要单纯依赖User-Agent判断登录设备,同版本同类型浏览器的UA可能完全一致,会导致误判

内容的提问来源于stack exchange,提问作者user18246964

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.30 18:01:04