简介:JSP开发中,'同一账号同一时间仅允许登录一次'是常见的账户安全需求,这份轻量级示例工程基于Session机制和过滤器实现,面向Java Web开发人员,适合需要快速掌握单点登录或会话唯一性控制的中初级学习者。rar压缩包共35个文件,大小仅351KB,包含4个JSP页面、6个Java源文件及对应class文件、15个tld标签库描述、2个jar依赖库与相关XML配置文件,目录结构清晰,可直接借鉴其代码组织方式。已有1407人学习下载,是一份经过实践检验的参考案例。其中不仅演示了登录时创建Session、通过Filter拦截请求并校验重复登录的完整流程,还涉及多线程并发控制、Session超时设置、防会话劫持等安全细节,同时给出向OAuth2、JWT等更高级认证机制过渡的思路,能帮助读者从基础到进阶理解Web登录状态的工程化设计。 很多产品的PRD里对"单端登录"的需求描述通常只有一句话:"同一个账号,同一时间只能允许他登录一次。"这句话看着简单,真正落到后端实现,我们会发现它牵扯出一连串的问题:新设备登录时旧设备要不要被踢?怎么判断"同一时间"?Web端和App端算不算同一个端?被顶下去的设备能不能第一时间收到通知?这些问题没讨论清楚就直接写代码,返工是大概率事件。
这篇文章我从后端视角把这块需求完整拆一遍,从需求定义、存储选型、Redis互踢的落地思路、Session与JWT方案的取舍,到那些不踩一遍根本发现不了的坑,尽量讲透。无论你是刚接手一个要加"单设备登录"的系统,还是想把自己项目的登录体系做得更稳,这篇都适用。
1. 先把需求拆明白:踢旧、拒新,还是一场业务规则的排列组合
1.1 "第二个设备登录时"到底要发生什么
当第二个设备发起登录时,系统是让新设备直接踢掉旧设备,还是直接拒绝新设备?这是第一个要拍板的问题,两种模式对应完全不同的用户心智。
互踢模式:新设备登录成功后,旧设备的下一次请求失效,旧设备被强制下线。适合账号安全敏感、需要快速抢占会话的场景,网银、企业后台、视频会员基本都是这种。
拒绝模式:账号已经在线时,新设备登录直接报"账号已在其他设备登录,请先退出后再登录"。适合单账号强绑定单设备的场景,比如某些私有化部署的终端系统。
不要以为选互踢就完事了,还接着有一堆问题:旧设备被踢后要不要有明确提示?提示文案是"您的账号已在其他设备登录"还是"登录已过期"?如果用户同时在电脑和手机上用同一个账号,这是不是违规?这些都属于需求边界,不敲定清楚,demo写完一定会返工。
1.2 "同一时间"在工程上如何定义
HTTP协议是无状态的,后端其实判断不了用户"此时此刻是否正在使用某个设备"。我们能做的,是判断"当前请求携带的凭证"是否还处于有效状态。所以"同一时间只能登录一次"在工程上被拆成两个可以判定的问题:
- 当前请求携带的token是否还没过期?
- 这个token是不是该账号最新签发的那一个?
只要这两个问题都通过,就认定该请求合法;如果旧设备还在用旧token,第2个问题就过不去。整个单端登录设计的核心,就是服务端必须能回答"哪个token是最新的",这决定了我们必须在服务端保存一份会话状态,而不是天真地以为靠客户端传参就能完成互踢。
1.3 动手前必须和产品确认的四个问题
我强烈建议开发在排期前就把下面几个问题扔给产品确认,否则写完后端接口再改规则,成本很高:
- 一个账号最多同时允许几个端在线?是全平台只能一个,还是手机端一个、Web端一个?
- 新登录请求是互踢还是拒绝?如果新设备登录失败,要不要提供"强制上线"的二次确认?
- 被踢的设备需不需要收到实时通知?通知到什么程度?
- 后台需不需要有"强制下线某个用户"的管理功能?
这些问题不敲定,后面的数据结构和接口设计都会乱。我见过一个项目,上线第二周运营就反馈"用户在电脑上工作到一半被手机登录顶掉了",就是因为当初没确认互踢粒度,把"App端互斥、Web端不互斥"的需求误做成了"全平台互斥"。
2. 存储模型怎么选:一张表还是Redis,直接影响后续所有逻辑
2.1 简单系统的数据库方案:用户表加token字段
如果业务规模不大、并发量低,最朴素的做法是直接在user表上加会话字段:
- current_token:当前有效token
- token_expire_time:token过期时间
- last_login_time:最近登录时间
登录时,后台生成一个新token,用UPDATE把这个用户的current_token覆盖掉,同时刷新过期时间。请求进来时,接口按token查询用户,比对current_token是否一致。这个方案很直白,几十个并发的小系统完全够用。但它的毛病也藏不住:每次请求都要查一次user表,token量大的时候还得给token列建索引;token过期了也没法自动清理,得靠定时任务扫;后面要区分多端的话,一张表里的字段会越加越多。
有人会想:那我给current_token加个唯一索引,让数据库天然保证"唯一token"不就行了?这个思路方向对,但实操很痛苦。每次登录都要"先删旧token再插新token",数据库在并发插入时会因为唯一键冲突报错,你还得想办法处理这种冲突。而中间"旧token已删、新token未插"的空窗期,会让正在使用的用户意外掉线。数据库唯一索引适合做"唯一身份标识",不适合做"会话互斥标记"。
2.2 切到Redis的真实理由:过期、性能、分布式一致性
把"当前有效token"从数据库搬到Redis之后,三个问题迎刃而解:
- Redis的key天然带TTL,token过期后自动消失,不用写定时任务清扫;
- Redis的读写比数据库快一两个数量级,高频接口每次校验会话的开销可以忽略;
- 多实例部署时,各实例访问同一个Redis,互踢状态天然全局一致,不需要额外的同步机制。
所以对于大多数需要单端登录的中小型系统,Redis是目前综合成本最低的答案,Spring Session、各类语言SDK对Redis的支持也都很成熟。如果你的系统已经是集群部署,但会话状态还留在各自机器的本地内存里,那单端登录根本没法定制,因为A机器判断"你是最新会话"、B机器却不知道有这回事,这属于必须先解决的底层问题。
2.3 双键结构和键的过期策略
我用两种键来维护会话状态,核心思路如下:
- 键A:login:session:{userId},value存当前有效token,TTL设8天;
- 键B:login:token:{token},value存userId,TTL设7天。
校验请求时,先用键B确认token本身存在并拿到userId,再用键A里的token和当前token比较,不一致就说明被顶号。键A的TTL比键B多一天,是为了防止会话在用户活跃过程中提前被清掉。键A如果只set不设过期时间,时间长了会积累一堆僵尸key,内存白白被占,所以过期时间一定要给。这个双键结构看着简单,却是后面所有互踢逻辑的地基。
3. 基于Redis的互踢拦截链完整落地
3.1 登录时:生成token、写入会话、踢掉旧会话
登录校验通过之后,我习惯按三步走:
- 生成足够随机的token。直接用UUID也行,更稳妥的是用SecureRandom生成一段32字节以上的随机hex。不要用自增数字当token,太容易猜。
- 写入键B:先把login:token:{newToken}写进Redis,value存userId,设置7天过期。这一步先执行,保证新token可以被查到。
- 更新键A:把login:session:{userId}覆盖为新token,设置8天过期,同时把旧token对应的键B删掉。
这里有一个经常被忽略的顺序问题:第2步必须放在第3步之前,否则新token先成了"最新会话",但键B里还没有它,用户刚登录就发请求,会被误判为"未登录"。而第3步里的"更新键A + 删除旧键B"两步,日常顺序执行也可以,但并发请求多的时候就容易出竞态,这个我放到第5章专门讲。
用Java伪代码表达大概是这个意思:
String newToken = UUID.randomUUID().toString().replace("-", ""); stringRedisTemplate.opsForValue().set( "login:token:" + newToken, String.valueOf(userId), Duration.ofDays(7)); String oldToken = stringRedisTemplate.opsForValue().get("login:session:" + userId); stringRedisTemplate.opsForValue().set( "login:session:" + userId, newToken, Duration.ofDays(8)); if (StringUtils.hasText(oldToken)) { stringRedisTemplate.delete("login:token:" + oldToken); }3.2 请求校验时:确认"你是最新会话"
在网关或拦截器里对所有受保护接口做统一校验,逻辑用四步就能说清楚:
- 从Authorization头取出token;
- 查键B,也就是login:token:{token},如果查不到,直接返回"未登录或token失效";
- 查键A,login:session:{userId},和当前token做比较;
- 一致则放行,不一致则返回"账号已在其他设备登录"。
有一个常见误区:有些人认为互踢只要在登录接口里把旧token标记成失效就够了,但登录之后的每一次请求还是要靠拦截器来校验"最新会话"身份。所以哪怕互踢逻辑再简单,这个"最新会话比对"也绝对不能省,否则老会话还是能一直用着。
校验逻辑里还可以顺带做会话续期,也就是滑动过期。如果键B的剩余TTL低于1天,就重新设置成7天,这样长效在线用户不会被强制要求重新登录,但长时间不活跃的用户,会话还是会自然过期。注意滑动续期不要每个请求都刷新TTL,只在低于阈值时刷新,否则高并发下Redis的写压力会被过度放大。
3.3 主动退出与被动过期:收尾工作不能漏
用户主动退出时,要把当前token对应的键B删掉,让会话立即失效。键A不建议直接删,可以设置成空字符串或者让它走自然过期,避免出现"刚退出又发一个旧请求,被判成有效会话"的尴尬。
被动过期场景要区分为三种:键B自然到期、密码修改后强制旧token全部失效、账号被后台管理员强制下线。这三种都要走同一套"会话撤销"逻辑。尤其是密码修改,很多团队忽略了这一步,用户改完密码,旧设备拿着旧token照样能访问三天,这就是妥妥的安全漏洞。改密码本身应该让所有已签发会话失效,这跟互踢逻辑是天然配合的。
4. Session与JWT:两种常见技术栈下的互踢落地方案
4.1 基于Session的互踢:先解决Session共享,再谈互踢
如果你的老项目还是以Session为中心,互踢也可以做,但前提是Session必须放在Redis这种共享存储里。否则多实例部署时会出现"用户登录在A机器,请求被路由到B机器,B机器没有这个Session"的经典问题。
解决共享以后,逻辑就和第3章的Redis方案几乎一样:登录成功后,把当前Session ID记录到Redis中,比如login:session:{userId};每次请求先拿到Session,再和Redis里记录的Session ID比对。因为Session容器本身已经存了用户信息,互踢只需要额外存一个"当前Session ID"字段。这套方案适合结合Spring Session做旧工程改造,改造成本最小。但注意不要在Session里塞太多对象,否则Redis内存和序列化开销都会上来,尤其是每次请求都要反序列化整个Session。
4.2 JWT的"无状态"短板:Token签发了就收不回来
新项目里JWT很流行,因为服务端不用存会话,验签就能确认身份。可一旦要做互踢,JWT"无状态"的优点恰恰变成缺点:token一旦签发,在过期前你没有任何办法让它立刻失效。
要互踢,就必须给JWT补一层服务端状态,常见两种做法:
- 版本号方案:Redis里存login:ver:{userId}=当前版本号,登录时把版本号加1,并把版本号写进JWT的payload。请求时解析出版本号与Redis里的当前版本对比,不一致就拒绝。这个方案用一个小字段替代了存整个token的开销,内存非常省。
- 黑名单方案:JWT签发时带一个唯一ID,也就是jti。被踢时把这个jti写进Redis黑名单,TTL设为JWT的剩余有效期。请求时先查黑名单,命中就拒绝。
两个方案我都在项目里用过。版本号方案节省内存,但每次请求要多一次Redis读;黑名单方案实现直观、无需改签发逻辑,但黑名单条目会占用更多内存。归根结底,需要封禁、踢人这些能力,服务端状态层是绕不开的,别被"JWT完全无状态"的说法带偏。
5. 实战中躲不开的坑:并发、通知、多端组合与配套规范
5.1 并发登录的竞态:同一秒两台设备同时登录怎么办
这是我在生产环境真实遇到过的坑。用户两台设备几乎同时发起登录,后端收到两个请求:登录A先写了自己的token,登录B后写了自己的token,最后键A被后一个覆盖。本来结果也还行,但问题出在中间有一段窗口期,A设备的token已经被键A替换,而B设备还没完成"更新键A"的操作,结果双方在各自校验时都被拒绝,用户两边的登录体验都非常差。
解决办法有两种。一种是把"获取旧token、更新键A、删除旧键B"封装成一个Lua脚本,在Redis里原子执行,不留给并发任何窗口。另一种是给同一个userId的登录请求加分布式锁,用SET NX拿到锁的请求才允许走完整登录流程,拿不到锁的要么等待要么直接失败重试。如果用了Redis Cluster,Lua脚本涉及的多个key要尽量通过hash tag路由到同一个slot,否则会跨slot报错。相比之下,分布式锁方案逻辑更简单,排错也更容易。
5.2 被踢用户如何第一时间知道:WebSocket推送与轮询
只靠接口层的401错误码,用户不做下一次操作是感知不到被踢的。更稳妥的方案是,被踢发生时通过WebSocket或SSE向旧设备推送一个"kicked"事件,前端收到后立刻清理本地登录态并弹窗提示。没有WebSocket基建的老项目,至少要做到前端在切页、App回到前台时主动调用一次会话检查接口,发现40102就跳登录页。
这里有一个体验细节:被踢提示不要泄露新设备的任何信息。类似"您的账号已在某品牌手机上登录"这类文案看起来贴心,实际上反而可能被社会工程学利用。统一提示"您的账号已在其他设备登录,如非本人操作请及时修改密码"就足够了。
5.3 更复杂的限制:手机端只能登一台,同时允许电脑在线
很多产品规则其实是"一个手机端加一个Web端可以同时在线",只是同类端内互斥。这种场景不用改核心逻辑,只需要把键A的粒度改细:login:session:{userId}:{deviceType}。deviceType由客户端在登录请求中显式上报,比如iOS、Android、PC-Web、PC-Client。跨端之间互不干扰,同端之间互踢。
再进一步,如果产品要求"每个设备ID只能登录一个会话",那就在键A里再加deviceId。但设备ID通常由客户端生成并上报,刷机、重装App都可能变,安全要求不高的场景别做太死,否则误伤率太高。设备相关字段也容易被伪造,真要追求安全,得配合设备指纹和风控体系,或者利用官方SDK提供的设备唯一标识能力,不要自己拼一个"UA+版本号"就当设备ID用。
5.4 错误码、审计日志与后台强制下线:配套体系决定上限
互踢功能能不能让运营、客服、安全团队省心,取决于配套是否完整。首先要有一套稳定的错误码规范,我通常这样定义:
| 错误码 | 场景 | 提示文案 |
|---|---|---|
| 40101 | 未登录或token失效 | 请先登录 |
| 40102 | 账号在其他设备登录 | 您已在其他设备登录,如非本人操作请及时修改密码 |
| 40103 | 登录状态已过期 | 登录已过期,请重新登录 |
| 40104 | 被后台强制下线 | 账号已被管理员下线 |
其次,每次登录、被踢、强制下线都要打审计日志,记录时间、IP、设备、登录结果、被踢原因。客服接到"账号是不是被盗了"的投诉时,这套日志能直接给出答案;安全团队做异常登录检测时,也靠它找线索。
后台强制下线这个功能经常被做成"重置密码"或者"删除用户对象",这都不对。正确做法是复用会话撤销逻辑:找到该用户当前token对应的键B,删除或标记为被踢,再把键A清掉或覆盖。管理员在后台点一下"强制下线",用户的会话必须立刻失效,否则就又是一个"看着下线了、其实还能用"的Bug。
我在不同项目里分别用过数据库字段、Redis双键、JWT版本号这三种方案,踩过不少坑之后,目前最顺手的一套组合是:Redis双键存会话 + Lua脚本处理并发登录 + 明确的错误码 + WebSocket通知被踢方 + 完整审计日志。这套方案不算重,普通规模的Web服务都能扛住,以后要扩展多端组合限制,也只是改改键的粒度,不用推翻重来。
如果你正在接手这类需求,我的建议是先花半天把产品规则聊透,再动手写代码。单端登录看着是个小功能,真正做扎实了,并发控制、会话生命周期、安全审计和前端交互,每一块都需要提前设计。
本文还有配套的精品资源,点击获取