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
相关产品推荐
相关产品推荐

