基于 RuoYi-Vue 轻松实现单用户登录功能,亲测有效

前言

你有没有遇到过这种情况? 同一个账号,你在你的电脑登录了。 然后另一个人在他电脑上也登录了一下。 结果,你电脑上的系统突然就掉线了!

这就是------单会话登录

一个账号,只能在一个地方登录。 新设备一登录,老设备会自动被踢下线。

听起来有点霸道? 但很多场景下,这反而是刚需。

为什么需要它?

  1. 安全考虑 比如公司内部系统、财务系统。 账号不能多人共用。 防止员工把账号借给别人用。

  2. 授权控制 有些软件是按账号收费的。 你不允许一个账号多端使用。

  3. 防止并发操作 多人同时改同一条数据? 容易出问题。 单会话能避免这种冲突。

不做会怎样?

  • 账号共享泛滥
  • 安全审计失效
  • 数据被并发修改,出现脏数据
  • 出了问题,根本查不到是谁干的

解决方案有哪些?

常见做法有三种:

  1. 前端控制

    • 检测页面是否已打开
    • 但这根本防不住多设备
  2. Token比对 + 拦截器

    • 后端记录最新Token
    • 每次请求都校验
    • 老Token直接拒绝
  3. Redis存储会话 + 全局监听

    • 登录时存入Redis
    • key为用户ID
    • 新登录覆盖旧记录
    • 旧客户端下次请求就被拦截

我们这篇文章要讲的,就是第3种

相信很多朋友对若依这个框架都不陌生,因为RuoYi生态成熟,文档全,上手简单,二次开发方便。很多公司都在用。


实现步骤

下面基于 RuoYi-Vue 前后端分离的版本来实现。直接上代码

1. 核心常量定义

文件 : ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java 添加USER_SESSION_KEY常量,用于用户会话管理

java 复制代码
/**
* 用户单会话登录 redis key
*/
public static final String USER_SESSION_KEY = "user_session:";

文件 : ruoyi-common/src/main/java/com/ruoyi/common/constant/UserConstants.java 添加SINGLE_SESSION_ENABLED常量,用于定义配置键

java 复制代码
/**
 * 单会话登录配置key
 */
public static final String SINGLE_SESSION_ENABLED = "sys.account.singleSessionEnabled";

2. 系统配置服务扩展

文件 : ruoyi-system/src/main/java/com/ruoyi/system/service/ISysConfigService.java 添加selectSingleSessionEnabled()方法接口,用于获取单会话登录开关。

java 复制代码
/**
* 获取单会话登录开关
* 
* @return true开启,false关闭
*/
public boolean selectSingleSessionEnabled();

文件 : ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysConfigServiceImpl.java 实现单会话登录配置开关的读取逻辑

java 复制代码
/**
* 获取单会话登录开关
* 
* @return true开启,false关闭
*/
@Override
public boolean selectSingleSessionEnabled(){
	String singleSessionEnabled = selectConfigByKey(UserConstants.SINGLE_SESSION_ENABLED);
	if (StringUtils.isEmpty(singleSessionEnabled))
	{
		return false;
	}
	return Convert.toBool(singleSessionEnabled);
}

3. 数据库增加配置项脚本

sql 复制代码
INSERT INTO `sys_config` (`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (100, '单会话登录', 'sys.account.singleSessionEnabled', 'true', 'Y', 'admin', '2025-09-12 20:44:48', 'admin', '2025-09-12 20:44:48', '是否启用单会话登录功能(true开启,false关闭)');

该脚本执行后,可以在后台配置是否需要单会话功能,如图:

4. Token服务核心功能

文件 : ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java 添加方法:

  • kickOutOtherSession(): 踢出用户其他会话
  • getUserSessionKey(): 获取用户会话缓存key
  • deleteUserSession(): 删除用户会话映射
  • isLoginElsewhere(): 检查用户是否在其他地方登录

修改原有方法:

  • createToken(): 集成单会话登录检查
  • delLoginUser(): 清理用户会话映射
  • refreshToken(): 同步更新会话映射

TokenService.java全部完整代码如下:

java 复制代码
package com.ruoyi.framework.web.service;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.AddressUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysUserService;
import eu.bitwalker.useragentutils.UserAgent;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

/**
 * token验证处理
 * 
 * @author ruoyi
 */
@Component
public class TokenService
{
    private static final Logger log = LoggerFactory.getLogger(TokenService.class);

    // 令牌自定义标识
    @Value("${token.header}")
    private String header;

    // 令牌秘钥
    @Value("${token.secret}")
    private String secret;

    // 令牌有效期(默认30分钟)
    @Value("${token.expireTime}")
    private int expireTime;

    protected static final long MILLIS_SECOND = 1000;

    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;

    private static final Long MILLIS_MINUTE_TWENTY = 20 * 60 * 1000L;

    @Autowired
    private RedisCache redisCache;

    @Autowired
    private ISysConfigService configService;

    @Autowired
    private ISysUserService userService;

    /**
     * 获取用户身份信息
     * 
     * @return 用户信息
     */
    public LoginUser getLoginUser(HttpServletRequest request)
    {
        // 获取请求携带的令牌
        String token = getToken(request);
        if (StringUtils.isNotEmpty(token))
        {
            try
            {
                Claims claims = parseToken(token);
                // 解析对应的权限以及用户信息
                String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
                String userKey = getTokenKey(uuid);
                LoginUser user = redisCache.getCacheObject(userKey);
                return user;
            }
            catch (Exception e)
            {
                log.error("获取用户信息异常'{}'", e.getMessage());
            }
        }
        return null;
    }

    /**
     * 设置用户身份信息
     */
    public void setLoginUser(LoginUser loginUser)
    {
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
        {
            refreshToken(loginUser);
        }
    }

    /**
     * 删除用户身份信息
     */
    public void delLoginUser(String token)
    {
        if (StringUtils.isNotEmpty(token))
        {
            String userKey = getTokenKey(token);
            LoginUser loginUser = redisCache.getCacheObject(userKey);
            if (loginUser != null && configService.selectSingleSessionEnabled())
            {
                // 如果启用单会话登录,删除用户会话映射
                deleteUserSession(loginUser.getUserId());
            }
            redisCache.deleteObject(userKey);
        }
    }

    /**
     * 创建令牌
     * 
     * @param loginUser 用户信息
     * @return 令牌
     */
    public String createToken(LoginUser loginUser)
    {
        String token = IdUtils.fastUUID();
        loginUser.setToken(token);
        setUserAgent(loginUser);
        
        // 单会话登录:踢出该用户的其他会话
        kickOutOtherSession(loginUser);
        
        refreshToken(loginUser);

        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_USER_KEY, token);
        claims.put(Constants.JWT_USERNAME, loginUser.getUsername());
        return createToken(claims);
    }

    /**
     * 验证令牌有效期,相差不足20分钟,自动刷新缓存
     * 
     * @param loginUser 登录信息
     * @return 令牌
     */
    public void verifyToken(LoginUser loginUser)
    {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TWENTY)
        {
            refreshToken(loginUser);
        }
    }

    /**
     * 刷新令牌有效期
     * 
     * @param loginUser 登录信息
     */
    public void refreshToken(LoginUser loginUser)
    {
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        // 根据uuid将loginUser缓存
        String userKey = getTokenKey(loginUser.getToken());
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
        
        // 如果启用单会话登录,同时更新用户会话映射的过期时间
        if (configService.selectSingleSessionEnabled())
        {
            String userSessionKey = getUserSessionKey(loginUser.getUserId());
            redisCache.setCacheObject(userSessionKey, loginUser.getToken(), expireTime, TimeUnit.MINUTES);
        }
    }

    /**
     * 设置用户代理信息
     * 
     * @param loginUser 登录信息
     */
    public void setUserAgent(LoginUser loginUser)
    {
        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
        String ip = IpUtils.getIpAddr();
        loginUser.setIpaddr(ip);
        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
        loginUser.setBrowser(userAgent.getBrowser().getName());
        loginUser.setOs(userAgent.getOperatingSystem().getName());
    }

    /**
     * 从数据声明生成令牌
     *
     * @param claims 数据声明
     * @return 令牌
     */
    private String createToken(Map<String, Object> claims)
    {
        String token = Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
        return token;
    }


    /**
     * 从令牌中获取用户名
     *
     * @param token 令牌
     * @return 用户名
     */
    public String getUsernameFromToken(String token)
    {
        Claims claims = parseToken(token);
        return claims.getSubject();
    }


    private String getTokenKey(String uuid)
    {
        return CacheConstants.LOGIN_TOKEN_KEY + uuid;
    }

    /**
     * 踢出用户的其他会话(单会话登录)
     * 
     * @param loginUser 登录用户信息
     */
    private void kickOutOtherSession(LoginUser loginUser)
    {
        // 检查是否启用单会话登录
        if (!configService.selectSingleSessionEnabled())
        {
            return;
        }
        
        String userSessionKey = getUserSessionKey(loginUser.getUserId());
        String oldToken = redisCache.getCacheObject(userSessionKey);
        
        if (StringUtils.isNotEmpty(oldToken))
        {
            // 删除旧的token缓存
            String oldTokenKey = getTokenKey(oldToken);
            redisCache.deleteObject(oldTokenKey);
            log.info("用户{}的旧会话已被踢出,token: {}", loginUser.getUsername(), oldToken);
        }
        
        // 保存新的用户会话映射
        redisCache.setCacheObject(userSessionKey, loginUser.getToken(), expireTime, TimeUnit.MINUTES);
    }

    /**
     * 获取用户会话缓存key
     * 
     * @param userId 用户ID
     * @return 缓存key
     */
    private String getUserSessionKey(Long userId)
    {
        return CacheConstants.USER_SESSION_KEY + userId;
    }

    /**
     * 删除用户会话映射
     * 
     * @param userId 用户ID
     */
    public void deleteUserSession(Long userId)
    {
        String userSessionKey = getUserSessionKey(userId);
        redisCache.deleteObject(userSessionKey);
    }

    /**
     * 检查用户是否在其他地方登录
     * 
     * @param userId 用户ID
     * @param currentToken 当前token
     * @return 是否在其他地方登录
     */
    public boolean isLoginElsewhere(Long userId, String currentToken)
    {
        // 检查是否启用单会话登录
        if (!configService.selectSingleSessionEnabled())
        {
            return false;
        }
        
        String userSessionKey = getUserSessionKey(userId);
        String sessionToken = redisCache.getCacheObject(userSessionKey);
        
        return StringUtils.isNotEmpty(sessionToken) && !sessionToken.equals(currentToken);
    }

    /**
     * 根据用户名检查用户是否在其他地方登录
     * 
     * @param username 用户名
     * @return 是否在其他地方登录
     */
    public boolean isLoginElsewhereByUsername(String username)
    {
        // 检查是否启用单会话登录
        if (!configService.selectSingleSessionEnabled())
        {
            return false;
        }
        
        try
        {
            // 根据用户名查询用户信息
            SysUser user = userService.selectUserByUserName(username);
            if (user != null)
            {
                String userSessionKey = getUserSessionKey(user.getUserId());
                String sessionToken = redisCache.getCacheObject(userSessionKey);
                // 如果存在会话token,说明用户在其他地方登录
                return StringUtils.isNotEmpty(sessionToken);
            }
        }
        catch (Exception e)
        {
            log.error("检查用户{}单会话登录状态异常: {}", username, e.getMessage());
        }
        
        return false;
    }

    /**
     * 获取请求token(公开方法)
     *
     * @param request
     * @return token
     */
    public String getToken(HttpServletRequest request)
    {
        String token = request.getHeader(header);
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
        {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }

    /**
     * 解析token(公开方法)
     *
     * @param token 令牌
     * @return 数据声明
     */
    public Claims parseToken(String token)
    {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }
}

4. JWT认证过滤器增强

文件 : ruoyi-framework/src/main/java/com/ruoyi/framework/security/filter/JwtAuthenticationTokenFilter.java 修改方法: 在token验证过程中添加单会话登录检查,当检测到用户在其他地方登录时返回401错误

JwtAuthenticationTokenFilter.java完整代码如下:

java 复制代码
package com.ruoyi.framework.security.filter;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.TokenService;
import io.jsonwebtoken.Claims;

/**
 * token过滤器 验证token有效性
 * 
 * @author ruoyi
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            // 检查用户是否在其他地方登录(单会话登录检查)
            if (tokenService.isLoginElsewhere(loginUser.getUserId(), loginUser.getToken()))
            {
                // 用户在其他地方登录,返回错误信息
                AjaxResult result = AjaxResult.error(401, "账号已在其他地方登录");
                ServletUtils.renderString(response, JSON.toJSONString(result));
                return;
            }
            
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        else if (StringUtils.isNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            // 当getLoginUser返回null时,检查是否是单会话登录导致的token被踢出
            String token = tokenService.getToken(request);
            if (StringUtils.isNotEmpty(token))
            {
                try
                {
                    // 尝试解析token获取用户信息
                    Claims claims = tokenService.parseToken(token);
                    String username = (String) claims.get(Constants.JWT_USERNAME);
                    
                    // 检查该用户是否在其他地方登录(单会话登录检查)
                    if (tokenService.isLoginElsewhereByUsername(username))
                    {
                        // 用户在其他地方登录,返回错误信息
                        AjaxResult result = AjaxResult.error(401, "账号已在其他地方登录");
                        ServletUtils.renderString(response, JSON.toJSONString(result));
                        return;
                    }
                }
                catch (Exception e)
                {
                    // token解析失败,可能是无效token,让Spring Security处理
                }
            }
        }
        chain.doFilter(request, response);
    }
}

后端配置完成

5. 前端

前端其实不用改,但为了能够更清晰和直观的提示,在响应拦截器做一点小修改:

src/utils/request.js修改code == 401的部分

js 复制代码
if (code === 401) {
  if (!isRelogin.show) {
    isRelogin.show = true
    
    // 检查是否是单会话登录被顶下来的情况
    const isSingleSessionKicked = res.data.msg && (
      res.data.msg.includes('账号已在其他地方登录') || 
      res.data.msg.includes('被强制下线')
    )
    
    if (isSingleSessionKicked) {
      // 单会话登录被顶下来,直接提示并跳转登录页
      ElMessageBox.alert('您的账号已在其他设备登录,当前会话已失效,请重新登录', '系统提示', { 
        confirmButtonText: '重新登录', 
        type: 'warning',
        showClose: false,
        closeOnClickModal: false,
        closeOnPressEscape: false
      }).then(() => {
        isRelogin.show = false
        useUserStore().logOut().then(() => {
          location.href = '/index'
        })
      }).catch(() => {
        isRelogin.show = false
        // 即使点击取消也要跳转登录页
        useUserStore().logOut().then(() => {
          location.href = '/index'
        })
      })
    } else {
      // 普通token过期,提供选择
      ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { 
        confirmButtonText: '重新登录', 
        cancelButtonText: '取消', 
        type: 'warning' 
      }).then(() => {
        isRelogin.show = false
        useUserStore().logOut().then(() => {
          location.href = '/index'
        })
      }).catch(() => {
        isRelogin.show = false
      })
    }
  }
  return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
  ElMessage({ message: msg, type: 'error' })
  return Promise.reject(new Error(msg))
} else if (code === 601) {
  ElMessage({ message: msg, type: 'warning' })
  return Promise.reject(new Error(msg))
} else if (code !== 200) {
  ElNotification.error({ title: msg })
  return Promise.reject('error')
} else {
  return  Promise.resolve(res.data)
}

前后端代码全部搞定,前后端各自重启运行。

6. 测试

A浏览器先登录admin账号,B浏览器再登录。此时如果A浏览器再去请求接口,就会被顶出来了,如图:

大功告成,如果需要关闭该功能,直接配置单会话登录 改为false即可。

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

相关推荐
weixin_456164835 小时前
vue3 子组件向父组件传参
前端·vue.js
沉鱼.445 小时前
第十二届题目
java·前端·算法
努力的小郑6 小时前
Canal 不难,难的是用好:从接入到治理
后端·mysql·性能优化
赫瑞6 小时前
数据结构中的排列组合 —— Java实现
java·开发语言·数据结构
Victor3566 小时前
MongoDB(87)如何使用GridFS?
后端
Victor3566 小时前
MongoDB(88)如何进行数据迁移?
后端
小红的布丁7 小时前
单线程 Redis 的高性能之道
redis·后端
GetcharZp7 小时前
Go 语言只能写后端?这款 2D 游戏引擎刷新你的认知!
后端
周末也要写八哥7 小时前
多进程和多线程的特点和区别
java·开发语言·jvm
惜茶8 小时前
vue+SpringBoot(前后端交互)
java·vue.js·spring boot