十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Redis+JWT实现单端登录:同账号同时只能登录一次的完整方案

Redis+JWT实现单端登录:同账号同时只能登录一次的完整方案 简介一份面向JSP开发者的账号互斥登录控制源码示例解决“同一账号同一时间仅允许一个会话在线”的典型Web安全需求适用于需要限制重复登录的Java Web项目。压缩包为rar格式共三十五个文件包含十五个标签库描述文件、六个Java源码及对应编译类、四个JSP页面另附依赖库和XML配置资源包整体仅三百五十一KB目录按常见Web工程结构分为META-INF、WEB-INF、src等便于按模块检索。该资源目前已有一千四百零七人学习是理解session会话与登录校验流程的实用参考。通过阅读代码可学习利用session与过滤器实现登录唯一性当检测到新登录请求时如何查找已有会话并决定是否使其失效在并发访问时如何保证session状态一致以及如何通过自定义标签和JSP页面交互提升开发效率。内容适合具备基础JSP语法、希望增强Web认证安全性的初中级开发者下载研究。 做后端开发的基本都遇到过这个需求同一个账号同一时间只允许登录一次。早些年我还在做电商后台系统的时候产品经理就跟我说过一次类似的场景——运营账号被同事异地登录直接把正在做的活动配置给顶下线了那边一脸懵这边一脸怨。后来这需求又出现在会员App、在线教育、企业协作工具里基本成了账号体系里的一个标配功能。这个需求的本质就是会话互斥更准确一点叫单端登录Single Session Login。不少刚接触的人以为只要登录的时候给用户生成一个token然后把旧的删掉就可以了。等真上了生产环境才发现里面还牵扯到登录态存储、令牌主动失效、并发竞态、被顶掉之后怎么实时通知用户等一堆问题。这篇文章我就把完整方案拆开讲透从原理到选型从代码到避坑基于我在实际项目里的实践来写。无论你是第一次接到这类需求的后端新人还是在设计账号体系时需要做方案对比的技术负责人看完之后应该都能直接动手落地。1. 需求梳理同账号同时只能登录一次到底在解决什么问题1.1 这需求都是从哪些场景冒出来的很多人第一反应是“为了防止账号被盗用”但真去跟提需求的人沟通会发现背后往往是更具体的问题。我见过的主要有这几类会员类产品一个会员账号全家人共用平台希望限定单人在线逼迫用户购买更多会员席位。爱优腾这类视频平台非会员套餐通常只允许同时登录一台设备就是这个逻辑。内部管理系统后台操作人员使用公账号做数据维护如果不限制A改了一版数据、接着B又改一版出了事都说不清楚是谁干的。限制单端登录之后责任链路清晰很多。游戏与实时对战类产品一个账号同时登录多端可能出现自问自答、刷分互顶的情况。为了保证公平性和服务端数据一致性必须做到同账号单会话。高价值交易平台证券、期货、网银这类系统对会话安全级别要求极高。同一账号在多个设备同时在线会显著增加会话劫持和越权操作的风险面。1.2 拆开来看这需求到底约束了什么把“同一时间只允许登录一次”这句话拆开其实包含了两层约束第一新的登录要成功旧的登录必须马上失效。用户拿账号在手机A上登录后又在手机B上登录B能正常进来A那边的继续操作应该立刻被拒绝。这是最基本的互斥逻辑。第二被顶掉的一方要知道自己为什么被顶下线。很多初版方案只做了服务端拒绝旧token但用户那边完全没有感知直到下一次请求才突然收到“登录已过期”的提示体验非常差。更理想的做法是服务端主动推送一个“你的账号在其他设备登录”的事件让用户明确知道发生了什么这也能降低账号被盗时的恐慌和误判。再往下拆就是技术层面的事了登录态放在哪里、怎么判断新旧会话、怎么处理并发请求、怎么做实时通知。这些才是真正决定方案好坏的细节。1.3 方案全景你们用过的无非这几种思路我接触过的单端登录实现基本可以归成三类“我不管你是谁”用一个布尔变量或状态字段标记账号是否在线。登录前检查这个标记为true就拒绝新登录。这是最简单的方案但有一个致命缺陷——无法区分“在线”标记是哪个设备的会话设置的用户只要忘记退出登录这个账号就再也登不上去了。实际项目中基本只会拿来做演示demo。“先来后到新顶旧”存储当前会话的标识比如token的哈希值每次请求都校验。新登录会把会话标识替换掉旧token随之失效。这种方案能区分新旧会话也能实现“踢掉旧设备”是绝大多数项目的落地首选。“新来请稍等旧的有话说”在前一种基础上增加会话列表、设备指纹、主动踢人接口、实时消息通知。适合需要精确管理多设备的业务比如微信的“登录设备管理”可以看到所有已登录设备还能主动移除某个设备。本文重点讲第二种方案因为它是投入产出比最高、最通用的一种做法。第三种方案是在第二种基础上做增强我会在第4节里单独展开。2. 核心方案为什么要用会话存储而不是只靠JWT2.1 无状态 JWT 的“有状态难题”现在很多项目都用JWT做登录凭证因为它天然携带用户信息、服务端不存状态、水平扩容友好。但JWT有一个很尴尬的特性签发之后在过期时间之前你没有办法从服务端主动让它失效。试想一下用户登录后拿到一个有效期为7天的JWT。另一台设备再登录时你又发了一个新的JWT。如果不做任何额外处理这两个JWT在过期前都是合法的。你想要“同账号只允许单端登录”就必须想办法记住“当前这个账号最新签发的是哪个JWT”然后在校验时做对比。所以单端登录的本质不是让token变得“可失效”而是给无状态的JWT加上一个有状态的“版本号”或者“会话标识”。这就是为什么几乎所有的落地方案都避不开一个外部存储——Redis。2.2 落地架构Redis 最新会话令牌选择Redis有很现实的理由会话校验发生在每一次请求的入口这是一个极高频率的读取操作。如果用数据库存储会话状态每个请求都要打一次DB数据库压力会非常大。Redis基于内存单线程模型下读写都是微秒级而且支持设置过期时间完美匹配会话状态这种“短生命周期、高频读写”的数据。具体怎么设计呢我的做法是把登录会话状态按下面的格式写入Rediskeylogin:user:{userId}value{tokenId}过期时间与登录态有效期一致比如7天对应关系登录成功后生成一个全局唯一的tokenId我用UUID作为value存入Redis同时生成JWTJWT的payload里带上这个tokenId。这里有个细节容易踩坑最好把tokenId放在JWT的payload里而不是直接用整个JWT字符串做value。因为JWT过长直接做value会白白占用Redis内存而且每次校验时向Redis传一个很长的key也不够优雅。用tokenId做valueRedis里存的只是一个36位左右的字符串干净利落。2.3 三个核心流程登录、校验、退出登录流程用户提交账号密码服务端校验通过。生成tokenIdUUID生成JWT把tokenId塞进JWT的payload里。执行一条Redis SET命令SET login:user:{userId} {tokenId} EX {过期秒数}。这一步会自动覆盖旧值相当于旧的会话从此刻起已经“失效”了。返回新的JWT给客户端。这里用的是SET命令而不是先GET再SET关键作用有两个一是原子性避免两个请求同时登录时发生竞态总是后面写入的覆盖前面写入的天然符合“新登录顶掉旧登录”的业务语义二是一步到位减少了网络交互。校验流程客户端在请求头携带JWT服务端先做JWT签名校验和过期校验。从JWT payload里取出tokenId拼出Redis key。执行GET命令拿到服务端当前存储的tokenId。比较两者是否相等相等放行不相等说明这个tokenId已经是旧会话了返回401/会话失效。这个过程最核心的一句话就是当前请求携带的tokenId必须和Redis里存储的tokenId一致。不一致的统统视为旧会话。退出流程退出登录时有两种做法。简单粗暴一点直接删除Redis keylogin:user:{userId}。不过这里有一个细节如果一个用户开了多个Tab页一个Tab页退出登录把key删了其他Tab页再请求就全会变成“会话失效”。这在大多数业务里是可以接受的但如果你希望退出只影响当前tokenId可以改成“先取出Redis里的值跟当前请求的tokenId比较相等才删除”。我个人的建议是直接删key逻辑更简单也更符合用户预期——退出了就是退出了所有端一起下线。3. 实操演示Spring Boot 集成 Redis 实现单端登录3.1 环境与依赖准备为了让你能直接照着做我用一套最常见的组合来演示Spring Boot 3.x Spring Data Redis JWTjjwt库 SSE用于实时通知。你不需要完全复刻我写的代码重点是理解流程。Maven依赖里需要加入这几个核心组件dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependencyRedis配置里有一个关键参数需要提前设置好就是key的序列化方式。很多新手在这里吃过亏如果没有自定义序列化器Spring默认使用JdkSerializationRedisSerializer存进去的key会带一串二进制前缀肉眼根本看不出是什么排查问题极其痛苦。我在项目里一贯的做法是用StringRedisTemplate或者自定义一个以String序列化为默认的RedisTemplate。这篇文章的示例都基于StringRedisTemplate来写。3.2 登录接口生成令牌并覆盖旧会话登录接口的核心代码长这样Service public class LoginService { private final StringRedisTemplate redisTemplate; public LoginService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public LoginResponse login(LoginRequest request) { // 1. 校验账号密码这里省略具体的认证逻辑 User user authenticate(request.getUsername(), request.getPassword()); // 2. 生成全局唯一的会话标识 tokenId String tokenId UUID.randomUUID().toString(); // 3. 生成 JWT把 tokenId 放进 payload String jwt Jwts.builder() .setSubject(user.getId().toString()) .claim(tokenId, tokenId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); // 4. 覆盖写入 Redis旧会话立即失效 String redisKey login:user: user.getId(); redisTemplate.opsForValue().set(redisKey, tokenId, 7, TimeUnit.DAYS); return new LoginResponse(jwt); } }这段代码里第4步是整个单端登录方案的“胜负手”。opsForValue().set()方法在key已存在时会直接覆盖旧值。也就是说第二次登录的请求进来Redis里的tokenId被更新了客户端A持有的旧tokenId从此和Redis里存储的不一致——下次请求一来校验直接不通过。3.3 全局拦截器每次请求都做会话比对有了登录时的会话存储接下来就是在所有受保护接口的入口处做校验。我建议用一个拦截器HandlerInterceptor来实现这样业务代码完全不用感知“我这个用户是不是单端登录”逻辑全都收敛在一处。Component public class SingleLoginInterceptor implements HandlerInterceptor { private final StringRedisTemplate redisTemplate; public SingleLoginInterceptor(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String jwt resolveToken(request); if (jwt null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } try { Claims claims Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(jwt) .getBody(); String userId claims.getSubject(); String tokenIdFromJwt claims.get(tokenId, String.class); // 关键校验当前 JWT 携带的 tokenId 是否等于 Redis 存的最新值 String redisKey login:user: userId; String latestTokenId redisTemplate.opsForValue().get(redisKey); if (latestTokenId null || !latestTokenId.equals(tokenIdFromJwt)) { // 已过期或被顶下线 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setCharacterEncoding(UTF-8); response.getWriter().write(账号已在其他设备登录或登录状态已失效); return false; } // 把用户信息放到请求上下文里方便业务代码获取 request.setAttribute(userId, userId); return true; } catch (JwtException | IllegalArgumentException e) { // JWT 签名校验失败或者过期 response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }这段代码里有一个值得注意的细节Redis里查不到tokenId时我也按“未登录”处理。为什么因为Redis里的会话key设置了7天过期只要超过7天key会被Redis自动清除此时无论jwt本身还有没有效我们都应该强制用户重新登录。这比单纯依赖JWT的过期时间更可靠因为JWT的有效期和Redis key的过期时间保持同步双保险。注册拦截器时注意排除登录接口本身不然用户还没登录就被拦下来了Configuration public class WebConfig implements WebMvcConfigurer { private final SingleLoginInterceptor singleLoginInterceptor; public WebConfig(SingleLoginInterceptor singleLoginInterceptor) { this.singleLoginInterceptor singleLoginInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(singleLoginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /error); } }3.4 被踢下线的实时通知SSE 推送做到前面这几步“同一账号同一时间只能登录一次”的逻辑已经完整了。被顶掉的用户在下次请求时一定会收到401这没问题。但更友好的做法是在用户被顶掉的那一刻立即通知他。实现实时通知的方案有几种WebSocket、SSEServer-Sent Events、客户端轮询。我的实践经验是如果只是做“账号被顶下线”这种单向通知SSE是投入产出比最高的选择。WebSocket虽然功能更强但需要维护双向连接、处理心跳和重连对于一个只需要服务端单向推送消息的场景来说有点杀鸡用牛刀了。SSE基于HTTP天生支持自动重连在Spring Boot里实现也特别简单。每个客户端登录成功后建立一条SSE连接把连接实例存在服务端内存里RestController public class SseController { private final MapString, SseEmitter clients new ConcurrentHashMap(); GetMapping(/sse/login-notify) public SseEmitter subscribe() { String userId (String) request.getAttribute(userId); SseEmitter emitter new SseEmitter(60_000L); clients.put(userId, emitter); emitter.onCompletion(() - clients.remove(userId)); emitter.onTimeout(() - clients.remove(userId)); return emitter; } }然后在登录接口里每次新登录覆盖旧会话之后主动向旧连接推一条消息SseEmitter oldEmitter clients.remove(userId.toString()); if (oldEmitter ! null) { try { oldEmitter.send(SseEmitter.event().name(kicked).data(你的账号在其他设备登录)); oldEmitter.complete(); } catch (IOException e) { // 客户端已经断开忽略 } }这里要留意一个细节发完消息之后要把旧的SseEmitter关闭。如果不关闭客户端那边会认为连接还活着下一次服务端推送时又会往这个已经失效的连接上写数据白白抛异常。关闭掉旧的连接客户端收到“kicked”事件后可以自己决定是弹窗提示还是跳转登录页。SSE的客户端实现也很简单原生JavaScript就支持const eventSource new EventSource(/sse/login-notify); eventSource.addEventListener(kicked, function (event) { alert(event.data); localStorage.removeItem(token); window.location.href /login; });4. 常见问题与避坑指南4.1 并发登录的竞态问题有没有想过这种情况用户在手机A和手机B上几乎同时点了登录按钮两个请求同时到达服务端同时生成了不同的tokenId同时执行Redis SET。理论上后执行的那个会把先执行的那个覆盖掉。但如果没有用原子操作而是先GET判断是否有会话、再决定是否覆盖两个请求同时GET到的都是“无会话”然后各自写入最终可能变成“先写后覆盖”登录成功的设备反而被踢掉用户体验非常诡异。解决方式前面已经说过了直接在登录接口里用SET覆盖写入而不是先查询再判断。Redis的SET是原子操作多个并发写请求只会有一个最终生效且一定是最后一个执行的生效。这正好符合“新登录顶掉旧登录”的语义。4.2 Token 续期和“续了旧命”的坑很多项目会给JWT加一个“续期”逻辑只要用户在有效期内有操作就给他重新签一个有效期更长的JWT。这时候要注意续期后的JWT是一个全新tokenId你必须同步把Redis里的值更新掉。如果忘了更新Redis就会出现“新JWT是签发了但Redis里还是旧的tokenId”紧接着用户下一次请求就会被拦截器误判为“被顶下线”。我在一个项目里就遇到过这样的线上事故用户每过一段时间就被强制下线一次排查了很久才发现是续期逻辑只更新了JWT没有同步更新Redis。为了避免这种问题最好把JWT续期和Redis会话更新的逻辑封装到一个方法里强制一起执行从设计上避免遗漏。4.3 多端策略同一个账号能否手机、电脑同时登录单端登录听起来是一个明确需求但真正做产品的时候“同一时间只允许登录一次”指的往往是同一类型设备。比如视频网站的同一账号手机端和电视端同时登录是允许的但两台手机同时登录就会被互顶。这就把方案从“全局只有一个tokenId”升级成了“同一设备类型只有一个tokenId”。实现上改造点很小Redis key不需要变但value可以设计成列表或者hashmap按设备类型分别存储设备类型Redis value手机tokenId_mobile xxx电脑tokenId_pc yyy电视tokenId_tv zzz校验时先解析当前请求来自哪个设备类型可以放在JWT payload里也可以单独一个header字段然后只跟对应设备的tokenId做比对。这样手机被顶掉不影响电脑电视那边也独立运作。4.4 主动踢人管理员强制下线指定用户很多时候产品还需要一个“管理后台强制踢人下线”的功能。比如客服发现某个账号存在异常操作需要在后台一键把它踢下线。这个功能在当前的架构下实现起来非常简单——直接删除Redis里的会话key就行public void kickout(Long userId) { String redisKey login:user: userId; Boolean deleted redisTemplate.delete(redisKey); if (Boolean.TRUE.equals(deleted)) { // 可以触发SSE通知告诉客户端“你已被管理员强制下线” SseEmitter emitter clients.remove(userId.toString()); if (emitter ! null) { emitter.send(SseEmitter.event().name(kicked).data(账号已被管理员强制下线)); emitter.complete(); } } }这个接口注意要加权限控制仅限管理员角色调用不然等于把踢人权限开放给了所有人。4.5 安全性补充别把tokenId暴露给前端最后说一个容易忽略的细节。tokenId的作用是作为会话的“唯一凭证”它在Redis里和userId一一对应。虽然tokenId本身不包含用户信息但一旦泄露攻击者完全可以把它伪造到自己的JWT里来冒充用户。所以我在前面代码里始终把tokenId放在JWT的payload里而不是让前端把它作为单独的请求头或者参数传过来。前端拿到的只有JWT本身tokenId对它来说是透明的。更严谨的做法还可以给Redis加一层校验确认当前请求的IP/设备信息跟登录时的记录一致这属于单端登录之上的安全增强工程上要不要做需要业务方权衡。我在实际项目中做这类功能时一个比较深的体会是限登录这个需求看着简单但它牵扯到的不仅仅是几行代码而是登录态管理的整体设计思路。与其等到上线后被各种边角问题折磨不如在动手之前就把会话的存储、校验、通知、退出、扩展策略都考虑清楚。文中的这套方案我在多个项目中实践下来稳定性和可扩展性都能满足大多数业务场景你可以直接以它为基础按需调整。如果你正在做账号体系希望这篇文章能帮你少踩几个坑。本文还有配套的精品资源点击获取
返回列表