同一账号在同一客户端类型只能登录一次

JeecgBoot 单点登录的核心技术栈为JWT(生成唯一登录凭证)+ Redis(存储登录状态 / 管理 token),所有核心逻辑集中在LoginController类中,是 JeecgBoot 权限体系的重要组成部分。

1.核心处理方法:handleSingleSignOn(单点登录核心)

接收用户名、新 token、客户端类型三个参数,完成 "登新踢旧" 的全部逻辑,

java 复制代码
private void handleSingleSignOn(String username, String newToken, String clientType) {
    // 步骤1:判断配置-是否开启并发登录(开启则跳过单点登录,直接返回)
    if (jeecgBaseConfig.getFirewall() == null || Boolean.TRUE.equals(jeecgBaseConfig.getFirewall().getIsConcurrent())) {
        log.debug("并发登录已启用:用户[{}]在{}端允许多地同时登录", username, clientType);
        return;
    }
    // 步骤2:根据客户端类型,匹配对应的Redis Key前缀(核心:客户端隔离)
    String redisKeyPrefix;
    if (CommonConstant.CLIENT_TYPE_APP.equalsIgnoreCase(clientType)) {
        redisKeyPrefix = CommonConstant.PREFIX_USER_TOKEN_APP;
    } else if (CommonConstant.CLIENT_TYPE_PHONE.equalsIgnoreCase(clientType)) {
        redisKeyPrefix = CommonConstant.PREFIX_USER_TOKEN_PHONE;
    } else {
        redisKeyPrefix = CommonConstant.PREFIX_USER_TOKEN_PC;
    }
    String userTokenKey = redisKeyPrefix + username; // 最终Key:前缀+用户名
    
    // 步骤3:获取该用户在当前客户端的旧token
    Object oldTokenObj = redisUtil.get(userTokenKey);
    if (oldTokenObj != null && !oldTokenObj.equals(newToken)) {
        String oldToken = oldTokenObj.toString();
        // 步骤4:踢掉旧登录-清理旧token的有效缓存,设置被踢提示(1小时过期)
        redisUtil.del(CommonConstant.PREFIX_USER_TOKEN + oldToken); // 清除旧token的核心缓存
        redisUtil.set(CommonConstant.PREFIX_USER_TOKEN_ERROR_MSG + oldToken, 
                      "不允许同一账号多地同时登录,当前登录被踢掉!", 
                      60 * 60); // 错误提示缓存1小时
        log.info("用户[{}]在{}端的旧登录已被踢下线,新token:{},旧token:{}", username, clientType, newToken, oldToken);
    }
    
    // 步骤5:保存新token-将新token存入Redis,设置与JWT一致的过期时间
    redisUtil.set(userTokenKey, newToken);
    redisUtil.expire(userTokenKey, JwtUtil.EXPIRE_TIME * 2 / 1000);
}

核心要点:

  • 配置开关isConcurrent为true时,直接跳过单点登录逻辑,允许多地登录;
  • 客户端隔离的核心是不同客户端使用不同的 Redis Key 前缀,确保登录状态互不干扰;
  • 旧 token 清理后,通过PREFIX_USER_TOKEN_ERROR_MSG + oldToken存储错误提示,供前端 / 接口校验时读取。

2.Token 生成与初始化:userInfo方法(登录成功后调用)

单点登录的前提是生成有效的 JWT token,并完成基础缓存初始化,该逻辑在userInfo方法中实现,核心步骤:

  1. 生成 JWT token:通过JwtUtil.sign(username, syspassword, clientType)生成含客户端类型的 token;
  2. 初始化 token 核心缓存:将 token 存入PREFIX_USER_TOKEN + token,并设置过期时间(PC 端为 JWT 过期时间的 2 倍,APP 端为 APP_JWT 过期时间的 2 倍);
  3. 调用单点登录方法:执行handleSingleSignOn(username, token, clientType),完成登新踢旧;
  4. 后续处理:设置用户信息、部门信息、字典数据等,返回给前端。
java 复制代码
// 生成token,并设置超时时间
String token = JwtUtil.sign(username, syspassword, clientType);
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token);
// 根据客户端类型设置过期时间
long expireTime = CommonConstant.CLIENT_TYPE_APP.equalsIgnoreCase(clientType)
? JwtUtil.APP_EXPIRE_TIME * 2 / 1000
: JwtUtil.EXPIRE_TIME * 2 / 1000;
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, expireTime);
obj.put("token", token);
// 执行单点登录逻辑-登新踢旧
handleSingleSignOn(username, token, clientType);

3.缓存清理:asyncClearLogoutCache方法(退出登录时异步执行)

用户主动退出登录时,需要异步清理所有相关缓存,避免阻塞登录请求,该方法通过自定义线程池cachedThreadPool实现异步执行,核心清理内容:

  1. 清理 token 核心缓存:PREFIX_USER_TOKEN + token;
  2. 清理 Shiro 权限缓存:PREFIX_USER_SHIRO_CACHE + 用户ID;
  3. 清理用户基础信息缓存:CacheConstant.SYS_USERS_CACHE + 用户名;
  4. 清理单点登录的 token 标识:PREFIX_USER_TOKEN_PC/APP/PHONE + 用户名;
  5. 记录退出日志。
java 复制代码
private void asyncClearLogoutCache(String token, LoginUser sysUser) {
    cachedThreadPool.execute(()->{
    redisUtil.del(CommonConstant.PREFIX_USER_TOKEN + token); // 清理核心token缓存
    redisUtil.del(CommonConstant.PREFIX_USER_SHIRO_CACHE + sysUser.getId()); // 清理Shiro缓存
    redisUtil.del(String.format("%s::%s", CacheConstant.SYS_USERS_CACHE, sysUser.getUsername())); // 清理用户信息缓存
    // 清理单点登录的token标识(全客户端)
    redisUtil.del(CommonConstant.PREFIX_USER_TOKEN_PC + sysUser.getUsername());
    redisUtil.del(CommonConstant.PREFIX_USER_TOKEN_APP + sysUser.getUsername());
    redisUtil.del(CommonConstant.PREFIX_USER_TOKEN_PHONE + sysUser.getUsername());
    // 记录退出日志
    baseCommonService.addLog("用户名: "+sysUser.getRealname()+",退出成功!", CommonConstant.LOG_TYPE_1, null, sysUser);
    });
}

完整单点登录执行流程

步骤 1:用户登录校验

用户提交用户名、密码、验证码,系统完成登录失败次数限制、验证码校验、用户有效性校验、密码校验(均通过后进入后续流程)。

步骤 2:生成 JWT token

调用JwtUtil.sign生成含用户名、密码、客户端类型(PC)的 token,并将 token 存入 Redis 核心缓存(sys:token:${token}),设置过期时间。

步骤 3:执行单点登录逻辑

调用handleSingleSignOn(username, newToken, CLIENT_TYPE_PC),首先判断配置文件的isConcurrent是否为false(开启单点登录)。

步骤 4:匹配 Redis Key 前缀

根据 PC 端匹配前缀PREFIX_USER_TOKEN_PC,生成最终 Key:sys:token:pc:用户名。

步骤 5:获取旧 token 并判断

从 Redis 中获取该 Key 对应的旧 token,若旧 token 存在且与新 token 不一致,则执行 "踢旧" 逻辑;若旧 token 不存在 / 与新 token 一致,直接保存新 token。

步骤 6:踢掉旧登录

  1. 清理旧 token 的核心缓存:redisUtil.del(sys:token:${oldToken});
  2. 为旧 token 设置被踢提示:sys:token:error:${oldToken},存储 "不允许同一账号多地同时登录,当前登录被踢掉!",有效期 1 小时;
  3. 打印日志,记录 "旧登录被踢下线" 的信息。

步骤 7:保存新 token 的单点标识

将新 token 存入sys:token:pc:用户名,设置与 JWT 一致的过期时间,作为该用户 PC 端的最新登录标识。

步骤 8:登录成功后续处理

  1. 清理该用户的登录失败次数缓存;
  2. 记录用户登录成功日志;
  3. 将 token、用户信息、部门信息等返回给前端。

旧登录被踢后的后续影响

旧登录的 token 因核心缓存被清理,当用户使用旧 token 访问系统接口时,接口会校验sys:token:${oldToken}是否存在:

  • 若不存在,直接返回 "token 无效";
  • 若存在sys:token:error:${oldToken},则返回对应的被踢下线提示,前端接收到后跳转到登录页。
相关推荐
牛奶12 小时前
AI辅助开发最佳实践:2026年新方法
前端·aigc·ai编程
C澒13 小时前
微前端容器标准化:公共能力标准化
前端·架构
Setsuna_F_Seiei13 小时前
AI 对话应用之 JS 的流式接口数据处理
前端·javascript·ai编程
Andya_net13 小时前
Spring | @EventListener事件机制深度解析
java·后端·spring
青柠代码录14 小时前
【Vue3】Vue Router 4 路由全解
前端·vue.js
无限大614 小时前
《AI观,观AI》:专栏总结+答疑|吃透核心,解决你用AI的所有困惑
前端·后端
蜡台15 小时前
element-ui 2 el-tree 内容超长滚动条不显示问题
前端·vue.js·elementui·el-tree·v-deep
Java面试题总结15 小时前
Spring @Validated失效?原因、排查与高效解决方案全解析
java·spring boot·spring
小小小小宇16 小时前
软键盘常见问题(二)
前端
小小小小宇17 小时前
软键盘常见问题
前端