首页 / 资讯中心 / 文章详情

谷粒商城单点登录实战:令牌+Redis实现多端登录与分布式会话

谷粒商城单点登录实战:令牌+Redis实现多端登录与分布式会话 ★ FEATURED ARTICLE
单点登录这个词做过电商项目的人应该都不陌生。我在带团队做谷粒商城这套实战链路的时候走到业务模块这一块单点登录是绕不过去的一道坎。为什么因为商城的业务形态天然就是多端并存的——用户可能在PC网页上浏览商品在手机App里加购物车在小程序里下单甚至在后台管理系统里处理订单。如果每个端都维护一套独立的登录状态用户每切换一个端就要重新输一次账号密码这种体验放在今天的电商环境里基本等于劝退。所以单点登录要解决的核心问题就一个一次登录多端通行且各端状态保持一致。这篇文章面向的是已经有一定Java Web基础、正在做分布式项目实战的开发者尤其是跟着商城类项目练手的朋友。我会把单点登录在谷粒商城这个业务场景下的完整落地方案拆开来讲从架构选型到代码实现从踩坑记录到排查技巧尽量做到你看完就能对着自己的项目复现。涉及到的核心技术点包括分布式会话、令牌机制、跨域携带、拦截器校验以及登录态续期这些内容我会结合商城业务的实际需求来讲不会干巴巴地堆概念。1. 单点登录在商城业务中的整体设计思路1.1 为什么商城项目必须做单点登录先想清楚一件事商城的用户行为链路是什么样的。一个典型用户早上在电脑上逛商品把几件东西加了购物车中午用手机App打开想看看有没有优惠券晚上回家又用平板下单。这条链路里涉及至少三个客户端如果每个客户端各自维护一套session那用户在PC上加的购物车手机端根本读不到因为服务端认不出这是同一个人。这不是技术炫技的问题是业务能不能跑通的问题。谷粒商城这类分布式商城项目服务拆分之后问题更明显。用户服务、商品服务、购物车服务、订单服务各自独立部署用户登录状态如果只存在某一个服务的session里其他服务根本拿不到。传统的单机session方案在分布式环境下直接失效因为请求经过负载均衡之后可能落到不同节点上session不共享就意味着登录态丢失。所以单点登录在这个场景下的本质需求可以归纳为三条第一用户在一个端登录后其他端能识别到同一个用户身份第二登录状态要在多个微服务之间共享第三令牌要有合理的生命周期管理既不能永久有效带来安全风险也不能太短导致用户频繁重新登录。1.2 方案选型为什么最终落在令牌加缓存的组合上单点登录的实现方案市面上常见的有几种我逐一说说为什么在商城项目里最终选了令牌加分布式缓存这条路。第一种是session共享方案把session统一存到Redis里各服务从Redis读session。这个方案改动小但有个硬伤它依赖cookie机制跨域场景下cookie携带很麻烦尤其是前后端分离之后前端域名和后端接口域名往往不一致cookie的SameSite策略和跨域限制会让这套方案很难受。商城项目现在基本都是前后端分离的所以这条路走起来别扭。第二种是纯JWT方案把用户信息编码进令牌本身服务端不存状态靠签名校验。这个方案无状态、扩展性好但问题在于令牌一旦签发就无法主动失效。用户点了退出登录或者管理员要踢掉某个异常账号纯JWT做不到即时生效只能等令牌自然过期。商城的登录态管理对安全性要求不低这个缺陷不能接受。第三种就是令牌加分布式缓存的组合方案也是谷粒商城实战里采用的方式。服务端生成一个随机令牌返回给客户端客户端每次请求带上这个令牌服务端拿令牌去Redis里查对应的用户信息。这个方案兼顾了令牌的灵活性和缓存的可控性令牌可以随时在服务端删除实现强制下线用户信息存在Redis里可以设置过期时间实现自动续期而且不依赖cookie跨域携带用请求头就行。我实测下来这套组合方案在商城这种多端多服务的场景里是最稳的。它的核心逻辑用一句话概括就是令牌是钥匙Redis是钥匙对应的柜子柜子里放着用户信息服务端认钥匙不认人。1.3 整体链路是怎么串起来的把整个单点登录的链路拆开看其实就三个环节登录签发、请求校验、状态续期。登录签发环节用户在任意一个端输入账号密码请求打到用户服务或者专门的认证服务认证通过之后服务端生成一个全局唯一的令牌把用户信息序列化后存进Rediskey用令牌value用用户信息同时设置一个过期时间。然后把令牌返回给客户端客户端自己存起来浏览器可以放localStorageApp放本地存储。请求校验环节客户端后续每次请求都在请求头里带上这个令牌服务端有个拦截器统一拦截从请求头取出令牌去Redis查用户信息查到了就放行并把用户信息塞进请求上下文查不到就返回未登录状态让客户端跳转登录页。状态续期环节用户每次带着有效令牌访问服务端在校验通过后刷新Redis里这个key的过期时间这样只要用户在活跃使用登录态就不会断只有长时间不操作才会自然过期。这个细节很关键不然用户用着用着突然被踢出去体验很差。这三个环节串起来就构成了一个完整的单点登录闭环。下面我会把每个环节的实现细节掰开讲。2. 令牌设计与缓存存储的核心细节2.1 令牌该怎么生成才靠谱令牌生成看起来简单实际上有几个坑。最忌讳的就是用自增ID或者时间戳这种可预测的值做令牌攻击者稍微猜一下就能伪造身份。谷粒商城实战里用的是UUID去掉横杠或者用更安全的随机字符串生成方式。我一般推荐用UUID加时间戳再拼一个随机盐做一次哈希这样既保证了唯一性又增加了不可预测性。代码大概长这样public String generateToken() { String raw UUID.randomUUID().toString().replace(-, ) System.currentTimeMillis() new Random().nextInt(10000); return DigestUtils.md5DigestAsHex(raw.getBytes()); }这里用MD5只是做一次摘要让长度固定不是说MD5本身有多安全。真正保证安全的是原始输入的随机性足够强。UUID本身已经是128位随机值加上时间戳和随机数碰撞概率基本可以忽略。令牌的长度也要考虑。太短容易被暴力枚举太长增加传输开销。32位十六进制字符串128位是个比较均衡的选择实测下来在商城这种QPS不算极端的场景里完全够用。注意令牌生成之后绝对不能在日志里明文打印。我见过有项目为了方便调试把令牌打进日志结果日志文件被拖库所有在线用户的登录态直接泄露。调试的时候最多打印令牌的前几位做标识。2.2 Redis里存什么、怎么存令牌生成之后要往Redis里写用户信息这里的存储结构设计直接影响后续的查询效率和扩展性。最直接的做法是用String类型key是令牌value是用户信息的JSON字符串。这个方案简单查询快适合用户信息字段不多的场景。但如果用户信息字段很多每次校验都要反序列化整个JSON开销就上来了。另一种做法是用Hash类型key是令牌field是用户各个属性value是对应值。这样查询单个字段很方便但整体读取还是要遍历。谷粒商城实战里用户信息字段不算特别多用String存JSON是最省事的我一般也推荐这个。存储的时候有几个细节要注意。第一用户信息里不要存敏感字段比如密码哈希、支付密码这些绝对不能进缓存缓存一旦泄露就是大事故。第二要存一个登录时间戳方便后续做登录态分析和异常检测。第三要存客户端的标识比如是PC端还是App端登录的这样后续做多端管理的时候有依据。过期时间的设置是个权衡。设太短用户稍微走神就掉线设太长安全风险增加。商城的常规做法是30分钟到2小时之间我一般设30分钟配合续期机制活跃用户不会掉线不活跃的用户半小时后自动失效。这个值可以通过配置中心动态调整不同业务场景用不同值。// 存储用户登录信息 String token generateToken(); String userJson JSON.toJSONString(userInfo); redisTemplate.opsForValue().set(login:token: token, userJson, 30, TimeUnit.MINUTES);key的命名前缀login:token:是个好习惯方便在Redis里按业务归类也避免和其他业务的key冲突。这个前缀在后续做批量清理或者监控的时候特别有用。2.3 多端登录的策略选择商城业务里有个绕不开的问题同一个账号在多个端同时登录怎么处理有三种策略。第一种是互斥登录新端登录把老端的令牌顶掉同一时间只允许一个端在线。这个策略安全性最高但用户体验差用户在电脑上登录着手机一登录电脑就掉线很烦。第二种是并行登录每个端登录生成独立令牌互不影响。这个体验好但服务端要维护一个用户到多个令牌的映射关系管理复杂度上升。第三种是限制数量比如最多允许三个端同时在线超过就把最早的踢掉。这个折中方案在商城场景里比较常见。谷粒商城实战里为了简化一般先做并行登录每个端独立令牌。如果要实现互斥或者限数需要在Redis里额外维护一个user:token:{userId}的集合记录这个用户当前所有有效令牌登录时做判断和清理。这块逻辑不难但要想清楚业务上到底需要哪种策略别一上来就做最复杂的。3. 登录签发与请求校验的完整实现3.1 登录接口的完整流程登录接口是整个单点登录的入口它的流程可以拆成五步参数校验、账号密码验证、生成令牌、写入缓存、返回结果。参数校验这步别省。我见过有项目直接拿前端传来的账号密码去查库结果传了个空值进去导致全表扫描。基本的非空校验、长度校验、格式校验都要做用注解或者手动判断都行。账号密码验证这步密码比对一定要用加密后的比对不能明文比。商城项目里用户密码一般是加盐哈希存储的登录时把用户输入的密码用同样的盐和算法哈希一遍再比对。这里有个细节不管账号不存在还是密码错误返回给前端的提示要统一成账号或密码错误不能区分否则等于告诉攻击者哪些账号是存在的。生成令牌和写入缓存前面讲过了这里重点说返回结果的设计。返回给前端的不能只有令牌还要有用户的基本信息昵称、头像这些这样前端登录后能直接渲染用户区域不用再发一次请求。但返回的用户信息里同样不能有敏感字段。public MapString, Object login(String account, String password) { // 1. 参数校验 if (StringUtils.isBlank(account) || StringUtils.isBlank(password)) { throw new BizException(账号或密码错误); } // 2. 查用户 User user userMapper.selectByAccount(account); if (user null) { throw new BizException(账号或密码错误); } // 3. 密码比对 String encrypted encryptPassword(password, user.getSalt()); if (!encrypted.equals(user.getPassword())) { throw new BizException(账号或密码错误); } // 4. 生成令牌并写缓存 String token generateToken(); UserVO userVO buildUserVO(user); redisTemplate.opsForValue().set(login:token: token, JSON.toJSONString(userVO), 30, TimeUnit.MINUTES); // 5. 返回 MapString, Object result new HashMap(); result.put(token, token); result.put(user, userVO); return result; }这段代码里buildUserVO方法负责把数据库实体转成前端需要的视图对象过滤掉敏感字段。这个转换层别省直接返回实体类很容易把不该给的字段带出去。3.2 拦截器统一校验登录态请求校验的核心是一个拦截器它负责在请求进入业务逻辑之前把登录态验掉。这个拦截器要注册到Web配置里并且要配置好哪些路径需要拦截、哪些放行。放行的路径包括登录接口本身、注册接口、商品浏览这类不需要登录的公开接口。需要拦截的是购物车、订单、用户中心这些涉及个人数据的接口。这个白名单和黑名单的配置要仔细梳理漏配了会导致该拦的没拦多配了会导致公开接口也要求登录。拦截器的逻辑不复杂从请求头取令牌判空去Redis查查到就放行并把用户信息塞进ThreadLocal查不到就返回401。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { response.setStatus(401); return false; } String key login:token: token; String userJson redisTemplate.opsForValue().get(key); if (StringUtils.isBlank(userJson)) { response.setStatus(401); return false; } // 续期 redisTemplate.expire(key, 30, TimeUnit.MINUTES); // 塞入上下文 UserVO user JSON.parseObject(userJson, UserVO.class); UserContext.set(user); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里用ThreadLocal存用户信息是为了让后续的业务代码能方便地拿到当前登录用户不用一层层传参。但ThreadLocal用完一定要清理afterCompletion里那句UserContext.clear()不能少否则线程池复用的时候会出现用户信息串号的问题这是个很隐蔽的坑。提示拦截器里返回401之后前端要统一处理跳转登录页。这个逻辑放在前端的请求响应拦截器里做别在每个接口里单独判断。3.3 跨域场景下令牌怎么带前后端分离之后跨域是必然要面对的。令牌放在请求头里携带跨域的时候浏览器会先发一个预检请求OPTIONS服务端要正确响应预检否则真正的请求根本发不出去。服务端要配置CORS允许前端域名、允许携带自定义请求头Authorization、允许必要的方法。这里有个细节如果前端要携带cookieallowCredentials要设true但同时allowedOrigins不能用通配符*必须指定具体域名。不过我们这套方案令牌走请求头不走cookie所以allowCredentials可以设false配置上简单一些。前端这边每次请求都要在请求拦截器里把令牌塞进请求头。令牌存在localStorage里请求拦截器统一读取并附加。响应拦截器统一处理401清掉本地令牌并跳转登录页。// 请求拦截器 axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); // 响应拦截器 axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { localStorage.removeItem(token); window.location.href /login; } return Promise.reject(error); } );这套前端拦截逻辑是单点登录体验的关键它让令牌的携带和失效处理对业务代码完全透明业务代码只管发请求不用关心登录态。4. 登录态续期与退出登录的细节处理4.1 续期机制怎么设计才不突兀续期这件事做得好用户无感做得不好就是各种莫名其妙的掉线。前面拦截器里那句redisTemplate.expire就是续期的核心每次有效请求都把过期时间重置回30分钟。但这里有个问题如果用户一直开着页面不操作但页面本身有轮询请求比如消息通知那这些请求也会触发续期导致登录态永远不过期。这个行为对不对取决于业务商城的做法一般是区分活跃请求和后台请求只有用户主动触发的请求才续期后台轮询不续期。实现上可以在请求头里加个标识或者按接口路径区分。另一个细节是续期的时机。放在拦截器里每次请求都续Redis的写入压力会比较大。优化做法是判断当前剩余过期时间如果还大于某个阈值比如20分钟就不续只有快过期了才续。这样能减少大量无谓的写入。Long expire redisTemplate.getExpire(key, TimeUnit.MINUTES); if (expire ! null expire 20) { redisTemplate.expire(key, 30, TimeUnit.MINUTES); }这个优化在高并发场景下效果明显我实测过能减少大概六成以上的expire操作。4.2 退出登录要清理干净退出登录的逻辑很简单从请求头拿令牌删掉Redis里对应的key返回成功。但简单归简单有几个地方容易漏。第一前端本地存的令牌也要清掉否则用户点了退出本地还留着令牌下次请求又带上了虽然后端已经删了会返回401但体验上会有一次多余的失败请求。第二如果做了多端管理退出的时候要考虑是只退当前端还是退所有端。只退当前端就删当前令牌退所有端就要根据用户ID找到所有令牌批量删除。这个需求要提前和产品确认清楚。第三退出接口本身要允许未登录状态访问因为令牌可能已经过期了用户点退出的时候令牌失效这时候接口不能因为校验不过而报错应该直接返回成功。public void logout(String token) { if (StringUtils.isNotBlank(token)) { redisTemplate.delete(login:token: token); } }这个方法不抛异常令牌为空或者不存在都静默处理保证退出操作永远成功。4.3 强制下线的实现方式强制下线是运营和风控的刚需。比如用户举报账号被盗或者系统检测到异常登录需要立刻让某个用户的登录态失效。实现方式取决于令牌的存储结构。如果只存了login:token:{token}那要强制下线某个用户得先知道这个用户有哪些令牌。这就需要在登录时额外维护一个user:tokens:{userId}的集合记录该用户所有有效令牌。强制下线时从这个集合取出所有令牌逐个删除再删集合本身。public void forceLogout(Long userId) { String userTokenKey user:tokens: userId; SetString tokens redisTemplate.opsForSet().members(userTokenKey); if (tokens ! null) { for (String token : tokens) { redisTemplate.delete(login:token: token); } redisTemplate.delete(userTokenKey); } }这个集合的维护要注意同步登录时加进去退出时移除令牌自然过期时集合里的残留项要定期清理。可以给集合也设一个稍长的过期时间或者用定时任务扫描清理。5. 实操中踩过的坑与排查技巧5.1 令牌失效但用户以为还登录着这个问题的表现是用户明明刚登录过操作一会儿就提示未登录。排查下来最常见的原因是续期没生效。可能的情况有几种拦截器里忘了写expire那行expire的key拼错了或者续期逻辑被某个条件分支跳过了。排查方法很直接登录后拿令牌去Redis里TTL一下看剩余时间然后发一个业务请求再TTL一次看时间有没有重置。如果没重置就顺着拦截器代码查续期逻辑。我一般会在拦截器里加一行debug日志打印续期前后的TTL定位起来很快。还有一种隐蔽情况是Redis的过期策略配置问题或者Redis内存满了触发了淘汰。这种要看Redis的监控指标evicted_keys如果持续增长说明内存在淘汰key登录态被误淘汰了。解决办法是给登录相关的key设置更高的优先级或者扩容Redis。5.2 多服务之间用户信息不一致分布式环境下用户信息在多个服务间传递容易出现不一致。比如用户在用户中心改了昵称但购物车服务里缓存的还是旧昵称。根因是用户信息在登录时被快照进了Redis后续用户信息变更没有同步更新缓存。解决办法有两个一是用户信息变更时主动更新所有相关令牌对应的缓存这个实现复杂但实时性好二是缓存里只存用户ID和基础标识具体信息各服务按需查库或查独立的用户信息缓存这个实现简单但多一次查询。商城的做法一般是折中缓存里存用户ID、昵称、头像这些高频展示字段变更时同步更新其他低频字段不缓存用到时再查。这样既保证了核心体验又控制了同步的复杂度。5.3 常见问题速查表问题现象可能原因排查方向解决方式登录后立即提示未登录令牌未正确写入缓存检查Redis写入代码和key拼写修正写入逻辑确认TTL正常用一会儿就掉线续期未生效对比请求前后TTL变化补上expire逻辑检查条件分支跨域请求令牌丢失CORS未允许自定义头看浏览器控制台预检请求配置allowedHeaders包含Authorization退出后仍能访问只清了前端没清后端检查退出接口是否删了缓存补上Redis删除逻辑用户信息串号ThreadLocal未清理检查afterCompletion确保每次请求后clear强制下线不生效未维护用户令牌集合检查user:tokens集合登录时维护集合下线时批量删这张表是我在实际项目中反复用到的排查清单遇到登录态问题先对着表过一遍大部分情况能快速定位。5.4 几个容易被忽略的安全细节令牌在传输过程中要走HTTPS这个不用多说明文传输的令牌等于把钥匙挂在门上。本地开发环境可能用HTTP但上线必须HTTPS。令牌的有效期不要设太长前面说的30分钟配合续期是合理的。有些项目为了省事设7天这期间令牌泄露就是7天的风险窗口。登录接口要做频率限制防止暴力破解。同一个账号或同一个IP连续失败多次就锁定一段时间这个用Redis的计数器很容易实现。敏感操作比如修改密码、支付要二次验证不能光凭令牌就放行。令牌只能证明你是登录用户不能证明当前操作是你本人意愿。6. 这套方案后续可以怎么扩展单点登录做完基础版之后其实还有很多可以深挖的方向。比如接入第三方登录微信、支付宝这些本质上是把认证环节外包出去拿到第三方凭证后在自己的体系里换发令牌整体链路和账号密码登录是一致的只是认证那一步换了实现。再比如做登录设备管理让用户能看到自己账号在哪些设备上登录着可以手动踢掉某个设备。这个功能依赖前面说的用户令牌集合把设备信息也存进去就行。还有令牌的无感刷新现在这套方案是滑动过期用户活跃就不掉线。但如果要做更精细的控制可以引入双令牌机制一个短期的访问令牌加一个长期的刷新令牌访问令牌过期用刷新令牌换新的这样安全性和体验能兼顾得更好。我个人在实际操作中的体会是单点登录这套东西原理不复杂难的是细节的完整性和一致性。登录、校验、续期、退出、强制下线每个环节都要考虑到任何一个环节漏了都会在某个场景下暴露问题。建议做完之后自己把多端登录、长时间挂机、强制下线这几个场景都手动过一遍比看代码靠谱得多。
阅读完成 · 觉得有帮助?
咨询建站