Spring Boot统一登录实战:认证中心、短令牌与集中会话设计
上个月帮一个朋友看他那套跑了六七年的后台系统八个子系统、三套账号库运维同事改个密码要挨个系统点一遍新同事入职第一周基本都在等账号开通。这类活儿我做过不下五回每次的核心都绕不开同一件事把登录这件事从各个业务系统里抽出来收拢到一个地方集中管。落到 Java 技术栈上最常见的落地形态就是 Spring Boot 统一登录——用一套认证中心承接所有子系统的登录请求,签发统一的身份凭证,业务服务只负责校验和消费身份,不再各自维护一套用户表和登录逻辑。这套东西能解决的问题很具体账号体系统一、登录状态跨系统共享、一次登出全站失效、权限变更实时生效、审计日志集中可查。它适合的人群也比较明确——手里有三套以上内部系统、正在做中台化改造、或者被“用户在 A 系统登录了,跳到 B 系统又要登一次”这种体验问题折磨的后端同学。如果你只有单体应用、用户量不到四位数,老实说没必要上这套东西,收益撑不起复杂度。下面我按自己的实际落地顺序,把设计思路、选型理由、代码实现、踩过的坑完整过一遍。1. 先把需求掰开统一登录到底统一了什么很多人一上来就聊 OAuth2、聊 JWT,结果做完了发现业务方要的是“我改完密码,其他系统的会话要立刻踢掉”,而你的令牌是无状态的、签出去就收不回来。这种返工我见过太多次。开工前把需求掰开揉碎,比选型重要十倍。1.1 三个典型场景,决定你的方案长什么样第一个场景是内部后台的多系统互通。用户在门户登录后,点击菜单跳到订单系统、财务系统,期望的是无感进入,不需要再输一次密码。这种情况使用者都在同一家公司内网,信任边界清晰,对“登出即时生效”的要求高,对跨域的要求低。第二个场景是自有产品的多端统一。Web、App、小程序共用一套账号,但三端的凭证存储方式完全不同——浏览器靠 Cookie,App 靠本地安全存储,小程序有自己的存储限制。这要求认证中心签发的是通用凭证,而不是绑死在 Cookie 上的会话 ID。第三个场景是对外开放平台的第三方接入。外部合作方要拿用户授权去访问资源,这时候就不能只做“登录态共享”了,得做授权委托,需要区分“你是谁”和“你能干什么”。我一般会把这三个场景分开评估,因为它们的复杂度差了一个量级。前两个可以靠自研的轻量方案覆盖,第三个基本必须走标准授权协议,别自己发明。很多项目失败就在于用做第一个场景的思路去做第三个,后面补授权模型补到崩溃。1.2 统一登录、单点登录、统一账号,别混为一谈这三个词经常被混着用,但在设计上是三件不同的事,拆开看能省掉大量沟通成本。统一账号解决的是数据层面的问题:所有子系统的用户数据来自同一张主表,不再各自维护一份,加人、改角色、离职禁用只在一个地方操作。这是最基础的一层,也是最容易被忽略的一层——很多团队直接跳到做登录,结果发现三套系统的用户 ID 根本对不上,做了一半卡住。统一登录解决的是入口层面的问题:所有系统共用同一个登录页面和同一套认证逻辑,密码规则、验证码策略、失败锁定策略只有一份实现。这一层做完,用户的体验是“我在哪都是同一个账号密码”,但登录态不一定共享。单点登录解决的是状态层面的问题:在 A 登录了,B、C 就自动是登录状态;在 A 登出,B、C 一起失效。这一层才是真正的难点,涉及凭证怎么跨域传递、怎么在多个系统间同步失效。我的经验是必须按这三层的顺序推进,先把账号数据打通,再统一登录入口,最后做状态共享。反过来做,一定是反复返工。1.3 开工前必须落纸的验收清单不写清楚验收标准,后面测试阶段会被业务方一句“我觉得这里不对”反复拉扯。我通常会拉一份清单,让业务方逐条确认,写进需求文档里:用户在一处登录后,访问其余所有子系统,是否需要再次输入密码(区分首次访问和 Session 过期后的访问)在一处登出后,其余系统的会话失效延迟能接受多少秒(实时、5 分钟内、还是次日)同一个账号是否允许多端同时在线,允许多少个,超出后踢掉最早的还是拒绝新登录密码修改、角色变更、账号禁用这三种操作,分别要在多久内对所有系统生效凭证有效期多长,是否支持“记住我”,记住我之后多久需要重新认证第三方系统接入是否需要区分授权范围,是否需要用户显式确认最后一条特别关键,它直接决定你要不要引入标准授权协议。很多团队前面五条都谈妥了,做到一半发现要支持第三方,架构推倒重来。2. 选型这件事四种主流路线怎么挑方案选型没有绝对的好坏,只有匹配度。我把实际用过的四种路线摊开对比,你可以直接对着自己的场景挑。2.1 四种方案的横向对比方案核心思路优点明显短板适用场景共享会话(Session 集中存储)会话数据放集中缓存,各系统共享同一个 SessionId实现最简单,登出能立即失效跨域传递依赖 Cookie,前后端分离和移动端不好接纯服务端渲染的老系统群无状态令牌(JWT 直传)签名令牌自带用户信息,各服务本地验签无中心查询,性能好,天然跨端无法主动吊销,权限变更要等令牌过期服务间调用、短生命周期场景短令牌 集中会话令牌只带最小标识,真实状态存集中缓存可随时吊销,兼顾跨端和可控性每次校验要查一次缓存绝大多数中后台系统标准授权协议走授权码流程,令牌分访问令牌和刷新令牌支持第三方、支持授权范围实现成本高,调试链路长对外开放平台看到这张表基本就能定调了。我服务过的项目里,八成以上落在第三行——短令牌加集中会话。原因很实在:业务方既要“多端支持”,又要“禁用立刻生效”,这两个需求直接把纯无状态方案排除了。2.2 我为什么倾向“认证中心 短令牌 集中会话”先说为什么不用纯 JWT。JWT 最大的卖点是服务端不用存储,验签就能拿到身份,这在微服务调用链里确实爽。但它有个绕不过去的硬伤:签出去的令牌,在过期前收不回来。用户离职了、密码泄露了、管理员紧急禁用账号,只要令牌还在有效期内,拿着照样能访问。有人会说加黑名单,但一旦加了黑名单,每次请求还是要查一次存储,“无状态”的优势就没了,不如一开始就把状态集中管起来。再说为什么不用纯 Session。Session 的跨域传递靠 Cookie 的 Domain 属性,同主域下的子域能共享,跨主域就得走重定向跳转,前后端分离项目和 App 基本没法用。而且 Session 天然和容器绑定,集群部署要做会话复制或者粘性会话,运维上多一层负担。短令牌加集中会话的折中方案是这样的:认证中心签发的令牌只承载最小信息(用户标识、令牌版本号、过期时间),真正的用户状态、权限、在线设备列表全部放集中缓存。业务服务拿到令牌后,用统一的方式校验签名和有效期,然后再查一次缓存确认令牌是否仍然有效。这个查询看起来很重,实际上缓存单次读在毫秒级,而且可以做本地缓存兜底,实际压测下来对整体延迟的影响可以忽略。换来的是完全的可控性:禁用用户,缓存里把令牌标记失效,下一次请求立刻被拦;修改角色,更新版本号,所有端上的旧令牌自动降级;踢掉某台设备,精准删除对应的键。这些在纯无状态方案里都很别扭。2.3 认证中心和业务服务的边界怎么划边界划不清楚,后面认证中心会膨胀成一个什么都干的怪物。我踩过这个坑:一开始把用户信息查询、菜单权限、组织机构树全塞进认证中心,结果认证中心变成了单点故障源,它一挂全站都不可用。现在的划分原则是这样的。认证中心只做三件事:验证身份(账号密码、验证码、二次验证)、签发和吊销凭证、维护全局会话(在线设备、登录日志)。业务服务负责剩下所有事:用户资料、角色权限映射、组织架构、业务级的数据权限。认证中心下发的令牌里只放用户唯一标识和版本号,业务服务拿到标识后自己去查需要的数据,查不到就按无权限处理。这么拆的好处是认证中心足够薄,可以独立扩容、独立部署,挂了也只影响新登录,已登录用户的业务访问不受影响(前提是业务侧做了本地缓存兜底)。同时业务服务不需要为了接认证中心改自己的权限模型,接入成本大幅降低。3. 认证中心的结构设计与存储方案3.1 一次登录请求走过的完整链路把链路画清楚,后面写代码基本就是照着填。用户发起登录,请求先到网关或者认证中心的前置过滤器,做限流和参数校验,防止暴力破解;校验通过后进登录接口,查出用户记录比对密码哈希,同时检查账号状态(是否禁用、是否锁定、密码是否过期);身份验证通过后生成令牌,写两条缓存记录——一条正向的从用户维度记在线设备,一条反向的从令牌维度记归属用户;然后把令牌返回给调用方。后续的业务请求先到统一拦截器,从请求头或者 Cookie 里取出令牌,验签、校验有效期、查缓存确认令牌未被吊销,把用户上下文塞进线程变量,放行。业务逻辑执行完,拦截器在 finally 块里清理线程变量。登出流程反过来:删掉正向和反向两条缓存,如果是全局登出,还要把该用户所有设备的令牌一起删掉,并递增用户维度的令牌版本号,让所有未过期的旧令牌失效。3.2 Redis 键设计和容量估算键设计不合理,后面做在线设备查询、强制下线会非常难受。我用的键结构是这样的:# 正向下标:某用户在某设备上的当前令牌 auth:token:user:{userId}:{deviceId} - tokenHash TTL 令牌有效期 # 反向下标:某令牌属于谁 auth:token:rev:{tokenHash} - userId:deviceId TTL 令牌有效期 # 用户令牌版本号,登出全部设备时递增 auth:version:{userId} - version TTL 7天 # 登录失败计数,滑动窗口 auth:fail:{account} - count TTL 15分钟这里有两个设计要点值得说一下。第一,反向索引里存的是令牌哈希而不是令牌原文,即使缓存被拖库,攻击者也拿不到可用令牌;哈希用 SHA-256 就够,不需要加盐,因为令牌本身就是高熵随机串。第二,版本号是解决“全局登出”的关键——令牌里带上签发时的版本号,校验时和缓存里的版本号比对,不一致直接拒绝。这样全局登出只需要一次INCR操作,不用去遍历删键。容量估算我一般按这个公式算:键数量 ≈ 日活用户数 × 平均在线设备数 × 2(正反两条) 少量额外键。假设日活 50 万、平均 1.5 台设备,键数量约 150 万。每条记录按 200 字节算(含 key、value、过期时间元数据),Redis 自身有哈希表和过期字典开销,实际占用大概是裸数据的 1.5 到 2 倍,总量落在 450MB 到 600MB 之间。这个量级单实例完全扛得住,不需要上集群,但内存要按峰值的 2 倍预留,建议开 1.5GB 并且配好淘汰策略——注意这里必须用noeviction或者独立的实例,别和业务缓存混用,否则内存吃紧时把登录态淘汰掉,用户会被莫名其妙地踢下线。3.3 令牌载荷设计与签名密钥轮换令牌我用的是 JWT 格式,但载荷压到最小:{ uid: 10086, did: web-a1b2c3, ver: 12, iat: 1710000000, exp: 1710007200, jti: 9f8e7d6c }uid是用户标识,did是设备标识,ver是令牌版本号,jti是令牌唯一 ID(用来做反向索引的键,也方便排查问题时定位具体哪一次登录),iat和exp是签发和过期时间。不要在令牌里放角色、权限、手机号这些会变的信息——令牌是签出去就改不了的,放进去就只能等它过期。签名算法上,内部单体的认证中心用 HS256 足够,密钥放配置中心,定期轮换。如果业务服务是多个团队维护、不方便共享密钥,那就用 RS256,认证中心持私钥签发,业务服务拿公钥验签,公钥通过配置中心或者一个公开的 JWKS 端点分发。密钥轮换是个容易忽略的点。做法是配置里同时保留当前密钥和上一版密钥,签发只用当前密钥,验签两个都试。轮换后等一个完整的令牌有效期过去,再把旧密钥删掉。这样整个切换过程对用户完全无感,不用强制所有人重新登录。4. 代码落地从签发到校验的全链路4.1 工程结构与依赖选择我把认证中心拆成独立服务,包结构按职责分:auth-center ├── config 安全配置、缓存配置、跨域配置 ├── controller 登录、登出、刷新、会话查询 ├── service 身份校验、令牌签发、会话管理 ├── interceptor 统一鉴权拦截器 ├── context 用户上下文与线程变量 └── common 统一响应、异常、常量依赖上不需要引入完整的 Spring Security 全家桶。如果只是做统一登录,自己写过滤器加拦截器更轻,排查问题也更直观;如果要做标准授权协议、要支持多种认证方式(表单、短信、扫码、第三方),那还是用 Spring Security 打底,省得自己实现一堆安全细节。我这次用的是轻量方案,核心依赖只有 Web、Redis 客户端和 JWT 库。要注意 JWT 库的选择。尽量用维护活跃、API 清晰的库,别用那种几年前就没更新的。另外自己实现 Base64Url 编解码加 HMAC 签名也不难,好处是完全可控,坏处是要自己处理各种边界,我倾向于用成熟库。4.2 登录接口的完整实现登录接口的逻辑看起来简单,实际上要处理的边界很多。核心流程是这样的:PostMapping(/login) public LoginResult login(RequestBody Valid LoginRequest req) { String account req.getAccount(); // 1. 失败次数检查,超过阈值直接拒绝 String failKey auth:fail: account; Long fails redis.opsForValue().increment(failKey); if (fails ! null fails 1) { redis.expire(failKey, Duration.ofMinutes(15)); } if (fails ! null fails 5) { throw new BizException(尝试次数过多,请15分钟后再试); } // 2. 查用户并比对密码 User user userRepository.findByAccount(account); if (user null || !passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) { throw new BizException(账号或密码不正确); } // 3. 状态检查 if (user.getStatus() ! 1) { throw new BizException(账号已被禁用); } // 4. 清理失败计数 redis.delete(failKey); // 5. 确定设备标识,多端互踢在这里处理 String deviceId resolveDeviceId(req); handleDeviceLimit(user.getId(), deviceId); // 6. 签发令牌 int version getVersion(user.getId()); String token tokenService.issue(user.getId(), deviceId, version); // 7. 写缓存 sessionService.save(user.getId(), deviceId, token); return new LoginResult(token, tokenService.expireSeconds()); }几个关键点解释一下。失败计数为什么要先自增再判断。因为网络重试、幂等场景下,先判断再自增会有并发窗口,导致实际尝试次数超过阈值。先自增再判断,最多是计数略微超前,安全性上是可以接受的。计数的键要带过期时间,而且要保证第一次自增时就设上,不能分开写(分开写的话如果进程在中间挂了,键就永不过期,用户会被永久锁定)。用 Lua 脚本把自增和设过期合并成一个原子操作最稳妥。密码比对一定要用常数时间的哈希算法。我用的 BCrypt,cost 参数设 10 到 12 之间。cost 每加 1,计算时间翻倍。10 在普通服务器上单次约 60 到 100 毫秒,12 约 250 到 400 毫秒。这个延迟对登录接口是可接受的(人感知不到),但攻击者想暴力破解成本就很高。别用 MD5、SHA-1 这类快速哈希,哪怕加盐也不够,GPU 集群一秒能算几十亿次。多端互踢的策略。我实现的是“限制同类设备数量”而不是“全局只能一个”。具体做法是给每个设备一个标识(Web 端用浏览器指纹加随机数,移动端用安装 ID),登录时把该用户在该设备上的旧令牌删掉,再看该用户的在线设备列表是否超限,超了就删最早的那个。这里有个细节:不同端的令牌有效期要分开,移动端可以给 7 天,Web 端给 2 小时,因为移动端重新登录的成本高得多。4.3 统一校验拦截器与用户上下文拦截器是所有业务请求的必经之路,写得不严谨会出大问题。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 白名单直接放行 if (isExcluded(request.getRequestURI())) { return true; } // 2. 取令牌,优先请求头,其次 Cookie String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } else { token getTokenFromCookie(request); } if (!StringUtils.hasText(token)) { throw new AuthException(未登录); } // 3. 验签与有效期 Claims claims; try { claims tokenService.parse(token); } catch (ExpiredJwtException e) { throw new AuthException(登录已过期,请重新登录); } catch (JwtException e) { throw new AuthException(登录凭证无效); } // 4. 版本号比对 Long uid claims.get(uid, Long.class); Integer currentVersion versionService.get(uid); Integer tokenVersion claims.get(ver, Integer.class); if (!Objects.equals(currentVersion, tokenVersion)) { throw new AuthException(登录状态已失效,请重新登录); } // 5. 缓存确认令牌未被吊销 String deviceId claims.get(did, String.class); String cached sessionService.getTokenHash(uid, deviceId); if (cached null || !cached.equals(hash(token))) { throw new AuthException(登录状态已失效,请重新登录); } // 6. 写上下文 UserContext.set(new LoginUser(uid, deviceId, claims.getId())); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); }这里有几个坑必须点出来。必须在afterCompletion里清理线程变量。ThreadLocal不清,线程池复用时下一个请求就会拿到上一个用户的身份,这种越权问题排查起来极其痛苦,而且往往是偶发的、很难复现。我见过一个线上事故就是这么来的,测试环境因为并发低一直没暴露,上生产后第二天就出了数据串号。版本号查询要加本地缓存。每次请求都查一次 Redis,量大时会是热点键。我的做法是在服务内加一层 Caffeine 本地缓存,有效期 5 秒,大部分请求命中本地缓存,少数穿透到 Redis。代价是版本号变更最多有 5 秒延迟,这个延迟对绝大多数业务是可以接受的。如果业务要求严格实时,那就把本地缓存关掉,或者用广播机制通知各节点失效本地缓存。Cookie 和请求头的优先级。浏览器环境走 Cookie 更自然(不需要前端每次手动带),但要注意 Cookie 在跨域请求里默认不带,需要配withCredentials。移动端和第三方走请求头。两个都支持,但要明确优先级,否则会出现令牌冲突导致身份错乱。4.4 服务间调用的上下文透传微服务之间调用时,原始请求的令牌默认不会带过去。这里要做两件事:把当前用户标识透传到下游,以及服务间内部调用不走用户鉴权。第一种做法是透传原始令牌。用调用框架的拦截器,把当前请求头里的Authorization原样带上。简单直接,下游服务复用同一套拦截器就能识别出用户。缺点是令牌在整个调用链路上反复传递,链路越长泄露风险越高。第二种做法是改用内部令牌。服务间调用时,上游把当前用户标识、请求追踪 ID 打成一个短期内部凭证(有效期几分钟,只在服务网格内有效),下游用另一套验签逻辑识别。这种方式隔离性更好,但要维护两套令牌体系。我用的是第一种加上来源校验:透传用户令牌的同时,额外带一个服务间调用头,下游的拦截器判断这个请求来自哪个服务,做白名单校验,非白名单来源的内部调用直接拒绝。这样既不增加复杂度,又堵住了外部伪造内部调用的口子。还有一种情况是异步任务。消息队列、定时任务里没有请求上下文,这时候要么在消息体里显式带上用户标识,要么就按系统身份执行。我倾向于显式带上,因为很多业务操作需要记录操作人,不带的话审计日志就是空的。4.5 续期、登出与多端互踢续期不要做成“每次请求都刷新令牌”,那样令牌会一直在变,前端处理起来很麻烦,而且缓存的键要跟着变,并发场景下很容易冲突。我用的方案是“滑动过期 定期刷新”:令牌本身有绝对有效期(比如 2 小时),但缓存里的会话记录是滑动过期的,每次校验时如果发现剩余时间不足 25%,就延长缓存的有效期。前端在收到 401 时调用一次刷新接口换新令牌,刷新接口限制频次(比如 30 秒内只能刷一次),避免刷新风暴。登出分三种粒度,这个必须实现完整,不然业务方会一直提需求:粒度触发场景具体操作当前设备登出用户主动点退出删正向和反向缓存,当前令牌立即失效全端登出用户修改密码、安全中心主动踢出递增版本号 删除该用户所有设备键强制下线管理员操作、风控触发指定 uid deviceId 删键,可加黑名单标记全端登出用版本号递增是最省事的,不用遍历键。但要注意版本号的存储也得有过期时间,不能永久驻留,我设的是 7 天,超过 7 天没有操作说明用户早已不用了,版本号归零也无所谓——不过这有个隐患,如果版本号过期归零,而某个超长有效期的令牌恰好还活着,就会出现版本号匹配的漏洞。所以我的做法是版本号过期时间一定要大于令牌的最长有效期,比如令牌最长 7 天,版本号就设 8 天。多端互踢除了登录时处理,还要考虑一种情况:用户在 A 设备登录,同时 B 设备正在使用。踢掉 B 之后,B 的下一次请求会返回 401,前端必须能正确跳转到登录页而不是卡在加载中。这个体验细节要在接入文档里写清楚,不然每个接入方都要栽一次跟头。5. 接入侧实操浏览器、移动端、老系统怎么接认证中心做完只是开始,真正耗时的是让各个业务方接进来。这一块的经验比代码值钱。5.1 浏览器端的 Cookie 与跨域配置浏览器端最简单的做法是把令牌放在 HttpOnly 的 Cookie 里,前端不需要关心令牌的存取,请求自动携带。但有几个属性必须配对:Set-Cookie: tokenxxx; Path/; Domain.example.internal; HttpOnly; Secure; SameSiteLax; Max-Age7200HttpOnly防 XSS 读取,Secure只在加密连接下发送,Domain设成顶级域可以让所有子域共享,SameSite要慎重——设成Strict的话,从外部链接跳进来不会带 Cookie,用户会看到“已登录但显示未登录”;设成Lax基本够用,只有跨站的 POST 请求不带。跨域配置这块,前后端分离的项目一定会遇到。最关键的规则是:开启携带凭证时,允许来源不能用通配符,必须指定具体域名。而且如果前端带了自定义请求头,后端要正确响应预检请求。Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin(https://portal.example.internal); config.addAllowedOrigin(https://admin.example.internal); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); }我踩过一个坑:允许来源里配了带路径的地址,结果一直不生效。AllowedOrigin只认协议域名端口,不能带路径,这个细节文档里写得不明显,调了两小时才发现。5.2 移动端和第三方应用的接入方式移动端不适合用 Cookie,走标准的“请求头带令牌”。App 把令牌存在系统的安全存储里(不要存普通偏好设置),每次请求放在Authorization头里。要注意的是 App 的网络库一般都有统一的请求拦截器,在那里加一次就行,别让每个业务模块自己处理。刷新逻辑要在网络层统一做:收到 401 时,检查是否有刷新令牌、是否正在刷新中。正在刷新的话,把当前请求挂起,等刷新完成再用新令牌重放。这里必须加并发控制,否则十个请求同时 401 会触发十次刷新,既浪费又容易因为刷新令牌轮换导致互相失效。第三方应用接入是最麻烦的。如果对方只是要读数据,可以用客户端凭证模式——给合作方分配一对凭证,他们用自己的凭证明换访问令牌,访问令牌不绑定具体用户。如果对方要代表用户操作,那就得走授权码流程,让用户在自己的平台上确认授权,认证中心发放授权码,合作方用授权码换令牌。这个流程的每一步都有安全要求(授权码一次性、必须有有效期、必须校验回调地址),自己实现容易漏,建议直接用成熟的安全框架打底。5.3 老系统灰度迁移的推进节奏让一堆跑了多年的系统改接入,是这件事里最耗人的部分。我的推进节奏是这样的:第一步,先做账号数据归并。把各系统的用户表拉出来,按手机号、工号、邮箱做匹配,生成一张映射表,人工确认冲突项。这一步不涉及代码改动,但必须先做完,否则后面登录了也找不到对应用户。第二步,做双轨运行。认证中心上线,但老系统还是走自己的登录。认证中心提供一个同步接口,老系统的账号变更、密码修改同步过来。这个阶段的目标是把认证中心的账号数据养准,业务零影响。第三步,选一个非核心系统试点。挑一个用户量小、出问题影响面小的系统,切成统一登录。跑两周,把各种边角问题(令牌过期提示、多端登录、Cookie 域)在试点上暴露干净。第四步,批量切换。按业务依赖关系排顺序,先切被依赖的,后切依赖方。每切换一个,保留一天的双登录入口(老入口加个开关),出问题能快速回滚。第五步,关闭老登录入口。全部切完之后至少观察一个月,确认没有遗漏的调用方,再关。关之前一定要查访问日志,确认老登录接口真的没有流量了——我见过关掉之后才发现有台打印机管理后台还在用老接口的。整个周期,七个系统我用了大概三个月。别指望两周搞定,光是对齐各方的窗口期就不止这个数。6. 故障排查实录与避坑清单6.1 高频故障速查表现象常见原因排查方向明明登录了,业务系统显示未登录Cookie 域配置错、跨域没带凭证、请求头被网关过滤看浏览器网络面板的请求头有没有带上令牌,看网关日志有没有透传登录后立刻又跳回登录页令牌写入失败、版本号不匹配、缓存写入和返回的令牌不一致对比返回的令牌和缓存里的哈希是否一致偶发越权,看到别人的数据线程变量没清理、异步线程没有传递上下文检查拦截器的清理逻辑,检查线程池使用用户被莫名踢下线缓存内存不足触发淘汰、版本号过期归零、多端互踢策略太激进查缓存的淘汰统计和内存水位大量请求同时 401令牌过期时间集中、刷新逻辑没做并发控制看令牌签发时间分布,看刷新接口的调用量移动端频繁掉登录令牌有效期太短、刷新失败、设备标识不稳定查刷新接口的成功率,查设备标识的生成逻辑性能下降明显每次请求查缓存成为瓶颈、本地缓存没开压测对比开启和关闭本地缓存的 QPS 差异6.2 令牌续期的并发风暴这个坑值得单独说,因为它的表现形式很吓人。现象是每天上午九点半左右,认证中心的 QPS 突然飙升到平时的十倍,缓存 CPU 打满,部分用户登录失败。排查下来原因很典型:令牌有效期设的是 2 小时,而大部分用户是八点半到九点集中登录的,到了十点半到十一点令牌集中过期,前端同时发起刷新,形成尖峰。解决办法有两层。第一层是打散过期时间,签发令牌时在基础有效期上随机加 0 到 15 分钟的偏移,让过期时间均匀分布。第二层是刷新请求合并,前端检测到 401 时,如果是同一页面发起的多个请求,只允许一个去刷新,其余等待结果。后端的刷新接口再加一个基于用户维度的分布式锁,防止同一个用户并发刷新。这个思路本质上和缓存击穿的处理是一样的:热点时间集中、并发量高、下游资源有限,那就打散加热点互斥。6.3 安全加固的必做项统一登录是整个系统的门,这里漏了后面全白搭。我列一份上线前必查的清单:密码哈希必须用自适应算法,成本参数不能太低;登录接口必须限流加失败锁定;令牌必须放在 HttpOnly 的 Cookie 或者安全存储里,不能放 localStorage;所有对外接口必须走加密连接;登出必须同时清理服务端状态,不能只删前端存储。补充几个容易漏的。令牌泄露后的止血手段要有:提供按用户、按设备、按时间范围批量吊销的能力,而不是只能一个个删。审计日志要记全:谁在什么时间从什么 IP 登录、登录成功还是失败、失败原因是什么,这些日志至少留 6 个月。敏感操作二次验证:改密码、改绑定手机、查看敏感数据,要重新验证身份,不能只凭登录态就放行。还有一点,别在令牌里放敏感信息。JWT 的载荷只是 Base64Url 编码,不是加密,任何人拿到都能解开看。用户的手机号、身份证号、内部角色标识,统统不要放。有人图省事把权限列表放进去,结果令牌长度超过 4KB,每次请求都要传这么大一坨,还容易触发请求头长度限制。我个人在实际推进这类项目时的体会是,技术方案的复杂度往往不是主要矛盾,真正的难点在于把“登录状态该在多久内失效”“允许多少端在线”这些模糊的业务诉求逼成明确的技术指标。这些指标定不下来,代码写多漂亮都要返工。所以每次开工,我都会先花两三天时间,把各业务方的负责人拉到一起,把前面那份验收清单一条条过完签字,后面的事情反而顺了。