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

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},则返回对应的被踢下线提示,前端接收到后跳转到登录页。
相关推荐
敲敲了个代码2 小时前
构建工具的第三次革命:从 Rollup 到 Rust Bundler,我是如何设计 robuild 的
开发语言·前端·javascript·后端·rust
加个鸡腿儿2 小时前
Nuxt SSR 水合错误处理实践:响应式布局的正确姿势
前端·typescript·nuxt.js
奋斗吧程序媛2 小时前
使用代理服务器的方式解决跨域问题
前端·javascript·vue.js
加个鸡腿儿2 小时前
解决 Nuxt SSR (服务端渲染) 环境下的水合错误 (Hydration Mismatch)
前端·typescript·nuxt.js
贾铭2 小时前
如何实现一个网页版的剪映(二)
前端·后端
用户600071819102 小时前
【翻译】Rozenite 构建解析:注入机制全揭秘
前端
失迭2 小时前
Cloudflare Tunnel + Zero Trust 稳定接入 Netcup VPS SSH
前端·javascript·github
一叶渡江2 小时前
Ghost docker安装踩坑
前端·cms
光影少年2 小时前
CSS盒模型是什么?box-sizing有什么作用?
前端·css