前言

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

这就是——单会话登录

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

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

为什么需要它?

  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常量,用于用户会话管理

/**
* 用户单会话登录 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常量,用于定义配置键

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

2. 系统配置服务扩展

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

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

文件: ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysConfigServiceImpl.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. 数据库增加配置项脚本

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关闭)');

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

f4218934daa82f8636cd64cea38ac028.png

4. Token服务核心功能

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

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

修改原有方法:

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

TokenService.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完整代码如下:

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的部分

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 最优雅的封装方式了,再也不用写重复代码了》

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]