ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

若依框架Token认证改造:实现多设备登录控制与并发管理

若依框架Token认证改造:实现多设备登录控制与并发管理 1. 项目背景与核心痛点最近在做一个基于若依分离版的后台管理系统客户那边提了一个很实际的需求一个账号能不能同时在手机和电脑上登录他们有些业务员需要在外用手机App处理紧急事务回到办公室又得在电脑上继续操作如果账号被踢下线工作流就断了。这听起来是个很常见的场景对吧但当我打开若依的默认配置时发现事情没那么简单。若依RuoYi作为国内非常流行的开源后台管理系统其前后端分离版本默认采用的是基于Token的无状态认证机制。这套机制的核心是用户登录成功后后端会生成一个JWTJSON Web Token返回给前端前端后续的每次请求都在Header里带上这个Token。后端通过验证Token的签名和有效期来判断用户身份。这种设计的初衷是为了服务端的无状态和扩展性但它天然带来一个问题服务端无法主动感知和管理Token的生命周期。默认情况下一个用户多次登录后端每次都会生成一个全新的、有效的Token。这意味着只要Token没过期之前登录的设备依然可以拿着旧Token访问系统。从安全审计的角度看这相当于账号在多个设备上“漫游”无法追溯具体的登录设备和登录时间也无法在发现风险时比如密码泄露立即让所有设备的会话失效。这就是我们常说的“单点登录”Single Sign-On的反面——我们需要的其实是“单设备登录”或“可控的多设备登录”。所以这个需求拆解下来核心就变成了如何在基于Token的无状态架构下实现服务端对Token的感知与控制从而限制或管理同一账号的并发登录设备数。这不仅仅是加几行代码那么简单它涉及到认证流程的改造、Token存储策略的选择以及并发控制逻辑的设计。接下来我会结合若依框架的源码一步步拆解实现方案和踩过的坑。2. 若依默认认证机制深度剖析要解决问题得先彻底搞明白若依是怎么做的。我们直接看核心代码主要集中在ruoyi-common模块的security包下。2.1 Token的生成与验证流程在TokenService类中登录成功后的关键操作是生成Tokenpublic String createToken(LoginUser loginUser) { String token IdUtils.fastUUID(); // 生成一个UUID作为Token键 loginUser.setToken(token); setUserAgent(loginUser); refreshToken(loginUser); // 刷新用户缓存 // 生成JWT将token作为JWT的subject主题或其他字段 MapString, Object claims new HashMap(); claims.put(Constants.LOGIN_USER_KEY, token); return JwtUtils.createToken(claims); }这里有个关键点若依实际上用了“双Token”策略。IdUtils.fastUUID()生成的是一个作为缓存键的Token我们称之为cacheKeyToken它会被存入Redis。而JwtUtils.createToken(claims)生成的是返回给前端的JWT Token这个JWT的Payload里包含了那个cacheKeyToken。当用户发起请求时在JwtAuthenticationTokenFilter过滤器中从请求头取出JWT Token。解析JWT得到里面的cacheKeyToken。用这个cacheKeyToken作为Key去Redis里查询完整的用户信息LoginUser对象。如果查到并且用户状态正常则认证通过。这个设计的精妙之处在于将无状态的JWT与有状态的Redis缓存结合了起来。JWT负责快速验证签名和过期时间避免每次查库Redis缓存则存储了最新的、完整的用户会话信息使得服务端具备了“状态”感知能力。这为我们控制多设备登录留下了至关重要的“抓手”——那个存储在Redis里的cacheKeyToken。2.2 默认逻辑下的多设备登录漏洞理解了流程漏洞就显而易见了。在默认的login方法中// 在登录成功后会调用recordLoginInfo记录登录信息但不会使旧Token失效 AsyncManager.me().execute(AsyncFactory.recordLoginInfo(username, Constants.LOGIN_SUCCESS, MessageUtils.message(user.login.success))); // 并且每次登录都会执行上面的createToken生成全新的cacheKeyToken和JWT。假设用户A在电脑上登录生成了cacheKeyToken: pc_token_123并存入Redis同时生成JWT_A给浏览器。 随后用户A在手机上用同一账号登录系统会生成全新的cacheKeyToken: mobile_token_456覆盖Redis中的旧值因为Redis的key是固定的通常是login_tokens:用户ID或login_tokens:用户名并生成JWT_B给手机App。此时Redis中只保存了最新的mobile_token_456。但是电脑端持有的JWT_A在过期时间默认2小时内依然可以正常解析出pc_token_123。当电脑端用JWT_A发起请求时过滤器会用pc_token_123去Redis查用户信息结果必然是查不到因为已被覆盖于是认证失败请求被拦截。这实际上是一种“后登录踢前登录”的行为但踢下线的动作有延迟旧JWT的有效期并且无法控制踢下线的范围是踢所有旧设备还是只保留N个。我们的目标就是要把这个被动的、覆盖式的行为变成主动的、可配置的管理策略。3. 解决方案设计与核心实现方案的核心思想是在Redis中从存储单个Token变为存储一个Token列表并以此为基础实现登录设备的追踪与强制下线。3.1 数据结构改造从单个KV到Hash列表首先要改变Redis的存储结构。原先的Key可能是login_tokens:1用户ID为1Value是一个LoginUser的序列化对象。现在我们需要它能存多个设备。方案一使用Hash结构推荐Key:login_tokens:用户ID(例如login_tokens:1) Value: 一个Hash Map。Hash的field可以设计为设备类型_设备标识(例如web_chrome_win10,app_iphone_13)或者直接用一个UUID。Hash的value是对应的LoginUser对象序列化后的字符串其中LoginUser对象需要增加字段如loginTime登录时间、ipaddr登录IP、browser浏览器、os操作系统、expireTime该设备Token的过期时间等。这种结构的优点是天然支持多字段一个Key下多个field-value对完美对应多设备。操作高效可以方便地通过HGET、HSET、HDEL管理单个设备通过HGETALL获取所有设备。空间节省相比为每个设备单独设一个Key避免了Key数量膨胀。方案二使用Set结构Key:login_tokens:用户IDValue: 一个Set里面存放每个设备Token的字符串。用户详细信息需要另存一个Key如login_token_detail:设备Token。 这种方案分离了索引和数据逻辑上清晰但操作时需要维护两个数据结构原子性更难保证且可能产生更多冗余数据。我们选择方案一。接下来需要修改LoginUser类和TokenService。3.2 关键代码实现登录、验证与登出第一步增强LoginUser对象在LoginUser类中增加设备标识字段。public class LoginUser implements Serializable { // ... 原有字段 private String token; // 这个token现在指代的是“本次登录会话的UUID”即Hash中的field private String deviceId; // 设备唯一标识可由前端传入或后端生成如web_fingerprint123 private String deviceType; // 设备类型如 web, app, mobile private Long loginTime; // 本次登录时间戳 private String loginIp; // 本次登录IP // ... 其他信息如browser, os等若依已有 }第二步改造TokenService的登录逻辑重点修改createToken和登录成功后的处理。Service public class TokenService { Autowired private RedisCache redisCache; // 登录成功后调用此方法 public String handleLoginSuccess(LoginUser loginUser, HttpServletRequest request) { // 1. 生成本次登录的设备ID和Token作为Hash的field String deviceId generateDeviceId(request); // 综合UA、IP等信息生成或由前端传入 String tokenUuid IdUtils.fastUUID(); loginUser.setToken(tokenUuid); loginUser.setDeviceId(deviceId); loginUser.setLoginTime(System.currentTimeMillis()); loginUser.setLoginIp(ServletUtils.getClientIP(request)); // 设置UserAgent等信息... setUserAgent(loginUser, request); // 2. 获取用户已有的登录设备列表 String userTokenKey getTokenKey(loginUser.getUserId()); MapObject, Object existingDevices redisCache.getCacheMap(userTokenKey); // 3. 检查并发登录限制假设配置为最多3个设备 int maxDevices 3; // 可从系统参数配置读取 if (existingDevices ! null existingDevices.size() maxDevices) { // 策略A禁止新登录抛出异常 // throw new ServiceException(账号已达到最大登录设备数限制); // 策略B更常见踢掉最早登录的一个设备 removeEarliestDevice(existingDevices, userTokenKey); } // 4. 将新设备信息存入Redis Hash // Hash的field设计为 tokenUuid:deviceId 或 deviceId方便后续查找 String hashField tokenUuid : deviceId; redisCache.setCacheMapValue(userTokenKey, hashField, loginUser); // 5. 为当前设备设置单独的过期时间Key可选用于快速检查 String deviceExpireKey getDeviceExpireKey(loginUser.getUserId(), deviceId); redisCache.setCacheObject(deviceExpireKey, tokenUuid, expireTime, TimeUnit.MINUTES); // 6. 生成返回给前端的JWT将 tokenUuid 和 deviceId 放入claims MapString, Object claims new HashMap(); claims.put(Constants.LOGIN_USER_KEY, tokenUuid); claims.put(Constants.LOGIN_DEVICE_KEY, deviceId); return JwtUtils.createToken(claims); } private void removeEarliestDevice(MapObject, Object devices, String userTokenKey) { // 遍历devices找到loginTime最小的那个LoginUser EntryObject, Object oldestEntry null; for (EntryObject, Object entry : devices.entrySet()) { LoginUser user (LoginUser) entry.getValue(); if (oldestEntry null || user.getLoginTime() ((LoginUser)oldestEntry.getValue()).getLoginTime()) { oldestEntry entry; } } if (oldestEntry ! null) { // 从Hash中删除该设备 redisCache.deleteCacheMapValue(userTokenKey, oldestEntry.getKey()); // 同时删除其独立的过期时间Key如果有 // ... 清理逻辑 } } }第三步改造JwtAuthenticationTokenFilter的验证逻辑过滤器需要同时验证JWT的有效性以及Redis中对应设备会话的存在性。String token getToken(request); if (StringUtils.isNotEmpty(token)) { try { // 解析JWT Claims claims JwtUtils.parseToken(token); String tokenUuid (String) claims.get(Constants.LOGIN_USER_KEY); String deviceId (String) claims.get(Constants.LOGIN_DEVICE_KEY); String userKey getTokenKey(claims.getSubject()); // 假设subject是userId // 组合Hash的field进行查询 String hashField tokenUuid : deviceId; LoginUser loginUser redisCache.getCacheMapValue(userKey, hashField); if (loginUser ! null) { // 验证通过可以更新一下该设备的最后访问时间等 loginUser.setLastAccessTime(System.currentTimeMillis()); redisCache.setCacheMapValue(userKey, hashField, loginUser); // ... 后续设置SecurityContextHolder } else { // Token无效或设备会话已被踢出 // 可以返回特定错误码告知前端“已在其他设备登录” } } catch (Exception e) { // JWT解析失败 } }第四步实现主动登出与强制下线用户主动登出当前设备从请求中解析出userId,tokenUuid,deviceId组合成Hash的field直接从Redis Hash中删除该field即可。用户强制下线所有设备或其他指定设备下线所有设备直接删除Redis Keylogin_tokens:用户ID。下线指定设备需要管理端提供一个接口传入用户ID和设备ID后端组合出field并从Hash中删除。密码修改后强制下线在修改密码的业务逻辑中调用“下线所有设备”的方法。3.3 并发控制策略与踢人逻辑这是整个功能最需要精细设计的地方。当登录设备数达到上限时如何处理新的登录请求禁止新登录最简单粗暴直接返回错误“账号登录设备数已达上限”。用户体验最差但最安全可控。踢掉最早登录的设备LRU - Least Recently Used如上文代码所示。这比较符合“闲置设备被顶替”的直觉。但需要记录每个设备的最后活动时间lastAccessTime而不仅仅是登录时间。踢掉最不活跃的设备基于最后活动时间判断。这需要每次请求都更新设备的lastAccessTime对Redis写入更频繁。允许用户选择被踢设备在达到上限时将当前已登录的设备列表设备类型、登录地点、时间返回给前端让用户自己选择踢掉哪一个。体验最好但前端交互复杂。实操心得在大部分内部管理系统场景下采用“踢掉最早登录设备”的策略是一个平衡点。实现时务必在LoginUser中增加lastAccessTime字段并在每次Token验证通过后更新它。这样“最早登录”的判断就更准确地变成了“最久未活动”。4. 前端适配与Token刷新机制后端改造完了前端也需要相应调整因为Token里现在包含了设备信息。4.1 前端请求的常态化携带前端登录后收到的JWT Token格式没有变但Payload里多包含了deviceId。前端无需特殊处理照常将Token存入本地存储如localStorage并在每次请求的Authorization头中携带即可。所有的设备信息都封装在Token里由后端解析和验证。4.2 Token自动刷新的兼容性处理若依默认可能带有Token自动刷新机制即在Token快过期时静默调用接口获取新Token。我们的改造必须兼容这一点。关键问题刷新Token时是生成一个全新的tokenUuid还是沿用旧的沿用旧的优点是设备标识不变在Redis Hash中的field不需要改变只需更新LoginUser中的过期时间等信息。操作简单对“多设备登录控制”逻辑无影响。生成新的更安全每次刷新都产生新的会话标识。但这意味着需要更新Redis Hash中的field先删除旧的再插入新的同时要保证这个更新操作是原子的否则在极短的时间内可能造成认证失败。建议采用沿用旧的tokenUuid方案。在刷新Token的接口中验证旧Token的有效性从Redis中查询。验证通过后不生成新的tokenUuid而是使用旧的。生成一个新的JWT包含旧的tokenUuid和deviceId返回给前端。更新Redis中该LoginUser对象的过期时间或其他信息。这样设备在Hash中的“席位”保持不变只做了“续期”操作逻辑清晰且稳定。注意事项如果安全要求极高担心Token被盗用后长期有效可以采用“生成新的”方案但务必使用Redis事务MULTI/EXEC或Lua脚本来保证“删除旧field”和“添加新field”的原子性避免出现并发问题。5. 管理后台功能扩展对于一个完整的管理系统仅有后端逻辑不够还需要给管理员提供可视化的管理界面。5.1 在线用户监控页面在系统监控菜单下新增“在线用户”页面。该页面需要列表展示展示当前所有在线用户的用户名、用户ID、登录IP、登录地点、设备类型、浏览器、登录时间、最后活动时间。数据来源后端提供一个接口遍历所有login_tokens:*的Redis Key解析出每个Hash中的所有LoginUser对象汇总成列表返回。注意性能用户量巨大时此操作可能较重可以考虑分页或异步导出。强制下线按钮在每一行操作栏提供“强制下线”按钮。点击后调用后端接口删除对应用户在Redis中对应的整个Key踢所有设备或指定的设备field。5.2 用户自助设备管理在用户个人中心可以增加“登录设备管理”功能。展示当前账号的所有登录设备调用接口后端根据当前用户的ID获取login_tokens:用户ID这个Hash中的所有设备信息过滤掉当前设备本身后返回。提供“退出其他设备”按钮用户可以一键踢掉其他所有设备的登录状态。后端实现就是删除Hash中除当前设备field之外的所有field。提供“退出指定设备”按钮用户可以对某个不认识的设备比如异地登录进行单独下线操作。这些功能不仅能提升安全性也给了用户更强的自主权体验更好。6. 上线部署与性能考量将这套机制应用到生产环境还需要考虑一些工程化问题。6.1 Redis内存与性能评估存储结构从简单的String变成了Hash内存占用会增加。每个用户的登录信息作为一个Hashfield数量等于其并发登录设备数。需要评估内存增长假设用户数1万平均每个用户2个设备在线每个LoginUser对象序列化后约2KB。则总内存约为10000 * 2 * 2KB 40MB。这在现代Redis实例中是可接受的。读写操作登录、验证、登出操作从GET/SET/DEL变成了HGET/HSET/HDEL时间复杂度依然是O(1)性能影响微乎其微。HGETALL用于管理查询需注意数据量。Key设计使用login_tokens:用户ID作为Key清晰且易于维护。可以考虑为这个Key设置一个全局的过期时间比如7天作为一个安全兜底防止长期不登录的僵尸会话残留。6.2 分布式会话一致性若依微服务版或集群部署环境下确保会话一致至关重要。Redis中心化存储我们的方案本身就依赖Redis作为中心存储所有微服务节点都从同一个Redis集群读写会话信息天然保证了一致性。Token刷新同步当某个服务实例刷新了某个设备的Token信息更新了Hash中的value其他实例在下一次读取时就能立即看到最新状态。无需额外同步。广播通知可选对于“强制下线”这种需要即时生效的操作除了删除Redis数据还可以通过Spring Cloud Bus、Redis Pub/Sub或WebSocket向所有网关和业务节点广播一个事件让它们清理本地可能存在的、与该用户相关的缓存如果有的话实现更即时的踢出效果。6.3 安全加固与防攻击设备标识生成deviceId的生成不能简单用UUID容易被伪造。建议结合用户不可控或难以伪造的信息如前端传入的设备指纹IP地址前两段User-Agent的哈希值。这样即使Token泄露攻击者在不同设备上也难以复用。Token泄露应对除了提供用户自助踢设备功能还应考虑加入异常登录检测如异地登录、新设备登录发送通知并允许用户一键冻结账号或下线所有设备。DDoS考虑登录接口和Token刷新接口可能成为攻击目标。需要配置合适的限流策略如使用Sentinel防止恶意刷登录耗尽Redis资源。7. 测试策略与常见问题排查任何改造都需要充分的测试来保障稳定性。7.1 多端并发登录测试用例设计以下测试场景正向用例用户A在Chrome浏览器登录然后在Firefox浏览器登录再在手机App登录假设上限为3。检查前三者是否都能成功在线且能正常访问需要认证的接口。踢人逻辑测试用户A已在3个设备登录。尝试在第四个设备如Edge浏览器登录。预期结果最早登录的那个设备Chrome会话失效其持有的旧Token访问接口返回401或特定错误码第四台设备登录成功。主动登出测试在设备A上点击“退出登录”验证设备A的Token立即失效设备B和C不受影响。强制下线测试管理员在后台强制下线用户A验证用户A的所有设备Token立即失效。Token刷新测试在设备A上等待Token临近过期触发自动刷新。验证刷新后设备A能继续访问且设备B和C的登录状态不受影响即设备A在Hash中的field没有变化。网络异常测试在登录、Token刷新过程中模拟网络超时检查系统状态是否一致有无产生脏数据。7.2 典型问题与排查思路问题一登录后旧设备依然能访问一段时间。排查检查Redis中旧设备对应的Hash field是否已被正确删除。检查JWT的过期时间是否设置过长。根本原因很可能旧设备持有的JWT尚未过期而我们的验证逻辑只检查RedisJWT本身的有效期验证在过滤器更早的阶段JwtUtils.parseToken就通过了但随后在Redis查不到会话信息而被拦截。确保错误信息明确前端收到特定状态码如 401 和自定义body后应引导用户重新登录。问题二达到设备上限后新设备登录失败但并未踢掉任何旧设备。排查检查maxDevices配置是否正确读取。检查removeEarliestDevice方法逻辑特别是遍历Hash和比较loginTime或lastAccessTime的逻辑。确认找到并删除了正确的field。查看Redis中对应Key的Hash内容确认field数量是否真的超过了限制。检查登录逻辑中handleLoginSuccess方法是否在存入新设备之前执行的踢人逻辑。顺序不能错。问题三管理后台查询在线用户列表非常慢。排查这通常是因为使用了KEYS login_tokens:*命令。在生产环境Redis的KEYS命令是阻塞的会扫描整个数据库绝对禁止使用。解决方案使用SCAN命令迭代在Service层使用RedisTemplate.scan方法分批获取匹配的Key。维护一个索引集合每当有用户登录HSET时同时用一个Set记录这个用户IDSADD online_users 用户ID。当用户所有设备都登出Hash被删除时从Set中移除SREM。查询在线用户时只需SMEMBERS online_users拿到用户ID列表再去批量HGETALL即可。登出逻辑需要仔细处理确保原子性。问题四在微服务环境下强制下线有延迟。排查这通常是因为网关或业务服务本地有用户信息的短期缓存如Spring Security的SecurityContextHolder。强制下线只删除了Redis的数据但已建立的连接在其本地会话过期前仍可能通过认证。解决方案缩短本地缓存时间尽量减少无状态服务的本地缓存时间。事件广播如前所述在强制下线后发布一个全局事件。各服务节点监听事件主动清理对应用户的本地上下文。每次请求都验证Redis这是最根本的解决方案我们的设计已经做到了这一点。延迟仅存在于“用户正在进行的某个长请求中”这个时间窗口通常很短。这套“若依分离版多设备登录控制”方案从问题分析、数据结构设计、核心代码实现、前后端联动到上线部署和问题排查形成了一个完整的闭环。它没有改变若依基于Token认证的根基而是巧妙地利用Redis Hash扩展了其会话管理能力在保持架构简洁的同时满足了精细化的安全管控需求。在实际项目中落地时可以根据具体的业务场景和安全等级灵活调整设备数上限、踢人策略和设备标识的生成规则。
返回列表