废品场模拟免安装绿色中文版
13.1G · 2025-10-27
你有没有遇到过这种情况? 同一个账号,你在你的电脑登录了。 然后另一个人在他电脑上也登录了一下。 结果,你电脑上的系统突然就掉线了!
这就是——单会话登录。
一个账号,只能在一个地方登录。 新设备一登录,老设备会自动被踢下线。
听起来有点霸道? 但很多场景下,这反而是刚需。
安全考虑 比如公司内部系统、财务系统。 账号不能多人共用。 防止员工把账号借给别人用。
授权控制 有些软件是按账号收费的。 你不允许一个账号多端使用。
防止并发操作 多人同时改同一条数据? 容易出问题。 单会话能避免这种冲突。
常见做法有三种:
前端控制
Token比对 + 拦截器
Redis存储会话 + 全局监听
我们这篇文章要讲的,就是第3种。
相信很多朋友对若依这个框架都不陌生,因为RuoYi生态成熟,文档全,上手简单,二次开发方便。很多公司都在用。
下面基于 RuoYi-Vue 前后端分离的版本来实现。直接上代码
文件: 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";
文件: 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);
}
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关闭)');
该脚本执行后,可以在后台配置是否需要单会话功能,如图:
文件: ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java
添加方法:
kickOutOtherSession(): 踢出用户其他会话getUserSessionKey(): 获取用户会话缓存keydeleteUserSession(): 删除用户会话映射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();
}
}
文件: 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);
}
}
后端配置完成
前端其实不用改,但为了能够更清晰和直观的提示,在响应拦截器做一点小修改:
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)
}
前后端代码全部搞定,前后端各自重启运行。
A浏览器先登录admin账号,B浏览器再登录。此时如果A浏览器再去请求接口,就会被顶出来了,如图:
大功告成,如果需要关闭该功能,直接配置单会话登录改为false即可。
《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》
《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》
《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》
《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》