资讯动态

Spring Authorization Server + JWT 统一多端登录态实战

发布时间:2026/10/14 15:17:40 来源:尧图企业网站定制
1. 为什么我铁了心要统一淘客系统的登录态1.1 做导购返利类系统多端到底有多“多”先交代一下背景。多数淘客/导购返利平台看起来是“一个网站”实际产品形态是散装的一堆入口。以我参与过的某导购返利平台为例用户侧就有 PC 官网、H5 商城、微信小程序、还有偶尔用的 App推广侧则包含个人推广小站、分享海报落地页、企业微信里的内部工具运营侧还有客服后台、风控后台、财务结算后台。每个入口背后都对着一套独立的用户行为路径。这个“多端并存”的架构本身没问题问题是早期每一端都在自建登录逻辑。PC 端用的 Spring Session 管理会话小程序端调微信的 code2session 拿到 openid 之后再自己生成一个随机 Token 存 RedisApp 端更简单粗暴直接在数据库建一张 token 表运营后台又是另外一套独立的账号密码体系。在这些体系之间用户身份、登录过期时间、权限角色完全没有共享标准。后果很现实用户在 App 上登录过隔天打开小程序还得重新授权绑定用户在小程序里点了退出App 的登录态还活着如果这时候设备被其他人拿着账号风险就没法控制客服想帮用户重置密码必须分别操作两套系统因为用户端和管理端根本认不出彼此的身份源。这些事一天两天看不出问题用户量大起来之后每一条都是工单和事故的来源。1.2 各端登录逻辑割裂的几个真实事故我印象最深的一次线上事故是这样的某个用户在小程序里参与了一档返利活动然后去 H5 页面查看返利进度结果 H5 返回“未登录”前端让他重新授权。用户觉得莫名其妙实际上是因为小程序和 H5 用了两套 token 体系小程序签发的 token 在 H5 的过滤器里根本不被认可。我们排查的时候要同时翻小程序 token 表、H5 session、以及 Redis 里的缓存键花了大半天才理清楚这个用户在小程序侧的 openid 和 H5 侧的 uid 到底是怎么映射的。还有一类事故是退出失效。用户在小程序里投诉说账号被盗我们查日志发现他的登录态在 App 端一直存在因为退出只清了小程序侧的数据。这种“单点退出”的问题一旦账号被恶意设备登录风控没法在其他端及时吊销令牌后面再补一封道歉邮件都晚了。这些事故的共同点就是登录态这个本该全局统一的东西被拆成了各自为政的小系统。要彻底解决不是靠每个端多写几个拦截器而是要把身份认证这件事从业务系统里抽离出来放到一个独立的认证中心里。1.3 治理目标统一身份源、令牌规范与退出能力我在项目启动第一周就把治理目标写了四句话贴在最显眼的地方统一身份源不管用户从哪个端进来最终对到的都是同一套用户主数据。统一令牌规范所有端共享同一种 AccessToken 格式业务方只认这一种身份凭证。统一协议流程Web、App、小程序都走同一套 OAuth2 兼容流程只是端侧适配方式不同。统一治理能力封禁、踢下线、一键退出在认证中心操作一次就对所有端生效。这四句话听起来不复杂但做起来最难的是第二点和第三点的组合。如果只是把各端的 token 都换成 JWT但每端还是自己发自己的那只是从“自研乱 token”换成了“自研乱 JWT”没有意义。真正要做的是让令牌的签发、续期、吊销都收敛到一个地方而这个地方最好具备协议标准的支撑不要自己造一个谁也看不懂的私有协议。基于这个判断我们把方案锁定在了 Spring Authorization Server JWT。2. 统一令牌链路的技术选型为什么是 JWT 配 Spring Authorization Server2.1 Spring Authorization Server 和自研认证的区别很多人对 Spring Authorization Server 的认知是“这是 Spring 官方出的 OAuth2 授权服务器”。这个描述准确但它能解决什么问题需要结合场景理解。OAuth2 协议里有大量容易写错的细节授权码一次性校验、redirect_uri 的白名单匹配、state 参数防 CSRF、客户端认证、refresh_token 轮换与失效、scope 的校验与声明。这些如果全靠自己手写不是写不出来而是每行代码都要长时间打磨才能达到生产级安全水准。Spring Authorization Server 把这些繁琐的协议逻辑封装成了可配置端点。我们团队要聚焦的只有一个自定义点用户怎么完成身份认证。原有的用户表、密码校验、短信验证、微信绑定全部通过自定义认证提供者接到授权服务器里。这样做的好处是当协议升级或者安全补丁发布时我们升级框架版本就能吃到收益不用自己维护一坨又复杂又没人敢动的登录核心代码。另一个实际的考量是我们后续要给开放平台提供第三方应用接入能力而 OAuth2/OIDC 是现成的行业标准。Spring Authorization Server 天然支持标准协议我们不需要再为第三方接入单独写一套授权接口。它的价值不在“帮你写登录”而在“把登录周边的协议复杂性标准化”。2.2 JWT 比 Opaque Token 更适合业务服务无状态化做统一认证时AccessToken 有两种主流选择JWT 或者 Opaque Token不透明令牌。大多数自研系统选 Opaque Token 是因为生成简单一个随机字符串存 Redis 就行但每次请求都要回源查询要么在网关查、要么在业务服务查链路长且依赖 Redis 的稳定性。业务服务一旦多了每个服务都要写一遍“去认证中心查 token 状态”的客户端维护成本爆炸。JWT 的核心优势是自包含。AccessToken 本身就是结构化数据里面携带用户 ID、端类型、角色、签发时间、过期时间等 claims。业务服务拿到 JWT 后只需要用公钥验签然后从 payload 里读身份信息不需要任何远程调用也不依赖 Redis 的可用性。在淘客系统这种请求量波动明显的场景下减少对中心化存储的依赖对稳定性提升非常直接。当然 JWT 也有它的代价无状态意味着没法在有效期内主动失效。我们后续在踢人和封禁场景里是通过额外的会话版本号机制来补足这个能力。这部分我会在第四章详细展开。这里先给一个结论JWT 负责“快速验身”授权服务器和 Redis 负责“状态治理”两者配合才是完整的方案。2.3 令牌的三种生命周期授权码、AccessToken 与 RefreshToken统一令牌链路里最常被忽视的就是生命周期设计。我在项目里确定的参数是这样的令牌类型有效期用途与注意事项授权码5 分钟一次性配合授权码模式换取 AccessToken必须校验 redirect_uri 和 state。AccessTokenJWT2 小时业务接口鉴权凭证验签即可读取用户身份不要求访问认证中心。RefreshToken30 天旋转式AccessToken 过期后用于换取新令牌每次刷新即作废旧值。为什么 AccessToken 取 2 小时太短会让用户频繁遇到 401刷新请求变多前端并发处理不好还会踩刷新竞态太长又容易在请求日志、浏览器历史或代理链路中泄露风险窗口过大。2 小时是淘客场景里“打开页面买东西、过一会儿再回来”这个交互节奏下比较折中的值。RefreshToken 用 30 天是产品层面的决定——用户一个月没打开应用后重新登录一次完全可以接受。RefreshToken 的旋转式设计是重点。每次刷新令牌时不只是返回一个新的 AccessToken还会返回一个新的 RefreshToken旧 RefreshToken 立即作废。这样即使攻击者截获了一个 RefreshToken实际可用窗口也非常短而且一旦用户正常操作导致 token 旋转偷来的旧 token 就会失效有利于风控发现异常。3. 核心实现认证服务端、JWT 生成与多端适配3.1 Spring Authorization Server 服务端配置要点先看服务端的依赖。我们的技术栈是 Spring Boot 3引入的依赖是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency授权服务器的安全过滤链配置是核心。关键在于给授权服务器单独设置一个匹配器让它和服务端的业务接口安全策略互不干扰。基础框架大致是这样Configuration EnableWebSecurity public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurerHttpSecurity configurer new OAuth2AuthorizationServerConfigurer(); http .securityMatcher(configurer.getEndpointsMatcher()) .with(configurer, server - server .authorizationServerSettings(AuthorizationServerSettings.builder() .issuer(https://sso.example.com) .build()) .tokenGenerator(tokenGenerator()) .authorizationService(authorizationService())); http .exceptionHandling(ex - ex .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint(/login), new MediaTypeRequestMatcher(MediaType.TEXT_HTML))); return http.build(); } }这里有几个值得注意的点。第一个是securityMatcher必须准确匹配授权服务器的端点路径否则认证请求会被业务端的安全策略拦截。第二个是issuer这个配置它会影响 JWT 的签发方声明业务服务校验时最好校验 issuer 一致防止跨环境伪造。第三个是授权服务器的登录入口我们这里使用/login跳转到统一登录页用户在这个页面完成账号密码、短信或者第三方授权绑定。3.2 接入已有用户体系与自定义身份认证淘客系统最大的历史包袱是用户体系已经存在不能推倒重来。Spring Authorization Server 默认的表单登录用的是框架内置的用户存储我们必须把它替换成自己已有的用户服务。实现方式是实现一个AuthenticationProvider在里面调用自己的用户查询和密码校验逻辑public class DaoUserAuthenticationProvider implements AuthenticationProvider { private final MemberAccountService accountService; private final PasswordEncoder passwordEncoder; Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username authentication.getName(); String rawPassword (String) authentication.getCredentials(); MemberAccount account accountService.loadByUsernameOrMobile(username); if (account null || !passwordEncoder.matches(rawPassword, account.getPassword())) { throw new BadCredentialsException(用户名或密码错误); } if (!account.isEnabled()) { throw new DisabledException(账号已被禁用); } // 这里构造 Spring Security 的认证结果并把用户主键等信息放进去 return new UsernamePasswordAuthenticationToken( new LoginUser(account.getId(), account.getRoles()), null, grantedAuthorities(account.getRoles())); } Override public boolean supports(Class? authenticationType) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authenticationType); } }自定义AuthenticationProvider之后把它注册到授权服务器的认证管理器里。这样用户提交账号密码后授权服务器内部仍然走标准的授权码流程但认证动作完全由我们的用户服务完成。这种做法的核心好处是业务服务不需要感知账号类型自注册、微信绑定、手机号注册只要用户对象能通过认证后续的令牌链路就是一致的。3.3 JWT 生成RSA 密钥、自定义 claims 与验签配置JWT 的签名算法我们选择了 RS256也就是非对称加密。原因很简单认证服务器保存私钥各个业务服务只拿公钥验签。即使某个业务服务被拖库公钥泄露也不会导致攻击者伪造令牌。JWK 源的配置主要用NimbusJwtEncoderBean public JwtEncoder jwtEncoder() { // 生产环境从密钥管理系统中加载不要硬编码在代码里 RSAPublicKey publicKey loadPublicKey(); RSAPrivateKey privateKey loadPrivateKey(); JWKSourceSecurityContext jwkSource new ImmutableJWKSet(new JWKSet( new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(sso-key-2024) .build() )); return new NimbusJwtEncoder(jwkSource); }在令牌生成时我们往 JWT 里加入了一些业务上必需的自定义 claims而不是只保留默认的 subjectJwtClaimsSet claims JwtClaimsSet.builder() .issuer(https://sso.example.com) .issuedAt(now) .expiresAt(now.plusHours(2)) .subject(String.valueOf(loginUser.getUserId())) .claim(clientType, clientType) .claim(roles, loginUser.getRoles()) .claim(userId, loginUser.getUserId()) .build();这里有一个容易踩的坑框架自带的 subject 可能在某些端侧解析时行为不一致所以我把用户主键单独放了一份在userId这个自定义 claim 里业务方优先取这个值不依赖 subject 的格式。clientType这个 claim 也很关键后面做多端互踢和设备管理时全靠它区分当前请求来自哪个客户端。业务服务的验签配置就简单很多直接用 Spring Security 的资源服务器能力spring: security: oauth2: resourceserver: jwt: issuer-uri: https://sso.example.com jwk-set-uri: https://sso.example.com/.well-known/jwks.json配置了 issuer-uri 之后框架会自动拉取 JWK 集合并校验 JWT 签名、过期时间、issuer 是否匹配。这样业务服务在代码层面只需要通过SecurityContext拿到Authentication对象从里面提取 userId 即可。3.4 刷新令牌与自动续期的无缝体验用户不会关心 AccessToken 什么时候过期他只关心“我还在操作你怎么让我重新登录”。要让这个体验无缝最关键的是前端把 401 拦截和刷新动作做好。我们的做法是所有请求统一走一个封装好的 client收到 401 后先判断当前是否有刷新任务在飞行如果有就等待同一个 Promise没有就发起刷新请求。这个“刷新单例”模式非常关键否则并发请求会同时触发多次刷新导致 RefreshToken 被旋转后其他刷新请求全部失败。服务端刷新接口是基于认证服务内置的/oauth2/token端点grant_type 为refresh_token。刷新时授权服务器会校验 RefreshToken 是否存在、是否过期、是否已经被使用过。Spring Authorization Server 默认在刷新成功后会签发新的 AccessToken 和新的 RefreshToken旧 RefreshToken 被标记失效。我强烈建议不要关闭这个旋转行为它是防止令牌重放的重要防线。3.5 小程序和 App 等非浏览器端的桥接适配多端适配里最麻烦的不是 Web而是小程序。小程序无法打开一个标准的浏览器授权页也不能完整参与 OAuth2 的 redirect 流程。我们最终采用的方案是做一个桥接接口小程序端调用wx.login获取微信临时 code。小程序把 code 传给后端的专用登录接口/mini/login。后端用 code 向微信接口换取 openid 和 session_key然后在自己的用户体系里找到或创建对应账号。后端签发一个一次性授权码返回给小程序。小程序拿着授权码调用认证服务器的/oauth2/token换取正式的 AccessToken 和 RefreshToken。这样做的效果是小程序的登录结果最终也能汇入同一套 JWT 认证链路。小程序不直接暴露密码避免在端侧做复杂的加密逻辑所有风险都收敛在服务端。App 端相对好处理我们用的是“系统浏览器打开授权页 自定义 scheme 唤起 App”的方式。用户在 WebView 外完成授权不让 WebView 保存密码能有效避免 WebView 缓存带来的账号风险。启动授权时带上state参数回调时严格校验 state 和 redirect_uri。端类型接入方式特别注意点Web 端标准授权码模式redirect_uri 白名单校验state 防 CSRF小程序桥接授权码模式需要 openid 与用户主数据合并绑定App系统浏览器 PKCE自定义 scheme 严格匹配避免劫持运营后台标准授权码 多因素认证单独的客户端 IDscope 限制 admin4. 登录态治理中的常见问题与排查实录4.1 AccessToken 过期后并发请求全挂掉这套体系上线后的第一个高峰期我们就遇到过一次事故。用户在微信小程序里一次性触发了多个接口请求比如首页加载、返利查询、商品详情三个请求并发发出去。AccessToken 恰好在这一刻过期三个请求全部返回 401。前端如果每个 401 都去刷一次第一个刷新请求成功后RefreshToken 已经被旋转后面两个刷新请求就会因为旧 RefreshToken 失效而失败用户界面直接弹“请重新登录”。这个问题的解法分两层。前端必须有一个全局唯一的刷新状态多个 401 只触发一次刷新其他请求等待同一个刷新结果。服务端也要做一个兜底校验对于已旋转的 RefreshToken再次使用应当被拒绝并记录异常日志用于风控判断是否发生了令牌重用。我后来总结了一个口诀AccessToken 过期是常态刷新请求必须串行化RefreshToken 旋转必须开前端 401 拦截必须统一收口。这几条如果都做到用户根本感知不到令牌过期。4.2 其他端踢不掉用户JWT 无状态与强制下线的平衡业务方提了一个很真实的需求管理员在后台看到某个账号在多个设备登录要能一键把其他设备踢下线。但 JWT 是自包含的签名有效、未过期的情况下服务端没有任何理由拒绝它就算我们把用户状态改成禁用旧的 JWT 依然能通过验签。我们的解法是增加一个登录态版本号机制。Redis 里存一个login_state:{userId}的 key值是当前有效会话的版本号。用户每次登录成功后版本号加一并在 JWT 的 claims 里带上这个版本号。业务服务验证完签名后再取一下 Redis 里的版本号和 JWT 里带的版本号对比不一致就拒绝访问。需要强制下线时直接递增版本号所有旧令牌立即失效。这套机制的代价是每次请求多了一次 Redis 读操作。为了平衡性能我们只对敏感操作做严格校验比如提现、修改密码、结算佣金等普通浏览类接口只验签不查版本号。这样既解决了 JWT 无法主动失效的问题又没有给所有接口都增加明显的延迟。4.3 时钟偏移导致的偶发 401有一段时间某个业务服务经常在晚上收到“认证失败”的告警但重试一下又恢复了。查了很久才发现是服务节点之间的系统时间存在偏移。JWT 校验时会判断exp和nbf如果当前时间比签发服务器时间慢了一分钟而令牌又恰好是刚刚签发的nbf校验就可能失败表现就是偶发 401。解决方式是在 JWT 校验器里配置时钟偏移容忍度。Spring Security 的NimbusJwtDecoder支持配置如下Bean public JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withJwkSetUri(jwkSetUri) .clockSkew(Duration.ofSeconds(30)) .build(); }同时我们在运维侧把所有节点都接入统一的时间同步服务双管齐下才彻底解决。这个问题的教训是只要涉及多个服务节点时间一致性就是认证系统里必须提前考虑的因素别等线上出了偶发故障再追。4.4 JWT 算法混淆与密钥轮换的坑另一个必须提醒的是 JWT 的算法混淆漏洞。有些 JWT 库在解码时如果不对alg做限制攻击者可以把RS256改成HS256然后用公钥内容作为签名密钥来签发令牌。我们在网关层对所有令牌的解析统一走NimbusJwtDecoder并且强制限制了算法只允许 RS256。自定义解析逻辑太多的小团队尤其要注意别在中间件里写一个“兼容多种算法”的万能解析器。密钥轮换上我们采用的是 JWK 多密钥机制。根因是长期使用同一个 RSA 私钥存在泄露风险所以每半年轮换一次。轮换时 JWK Set 里会同时存在新旧两个 key新的 JWT 使用新 kid 和私钥签名旧令牌在有效期内还能用旧公钥验签实现平滑过渡。操作流程是先在密钥管理系统生成新密钥发布到 JWK Set再让认证服务器切换到新密钥签发最后过一段时间下线旧密钥。4.5 常见问题速查表现象可能原因处理建议请求偶发 401重试成功服务节点时间偏移统一时间同步JWT 校验加 clockSkew刷新后依然一堆 401并发刷新导致 RefreshToken 旋转失效前端刷新单例服务端拒绝旧 RefreshToken 时记日志管理员踢不掉某设备JWT 无状态旧令牌仍在有效期内增加 loginState 版本号机制敏感接口实时校验某端登录后其他端被挤下线客户端配置了相同 scope 但未区分端每个端使用独立 clientIdJWT 里加 clientType 区分令牌验签通过但用户不存在各端历史数据存在两套用户主键统一历史用户映射确保令牌里的 userId 全系统唯一授权回调报了 redirect_uri 不匹配回调地址多端混用检查 RegisteredClient 的 redirectUris 白名单配置5. 一点实操心得我把这段放在最后是因为它比技术方案更重要。登录态治理这件事技术上最难的从来不是把 JWT 签发出来而是让团队所有人达成一个共识所有端都用同一个身份源所有身份凭证都走同一套标准协议。项目推进中一定会有人说“小程序有自己的一套更方便”“App 端直接调登录接口更快”这时候就把事故记录拿出来用事实说明分散登录的长期代价。另外如果你是在一个已经跑了很久的老系统上做这个改造我真心建议按“先收敛、再统一、后治理”的节奏走。第一步先把散落各端的登录接口统一到一个认证服务中把用户身份源对齐第二步再引入 JWT 和协议层第三步才做互踢、封禁这些高级治理能力。每步都保证可回滚线上出问题不至于全链路崩溃。我们这套体系上线稳定运行之后认证相关的故障工单量下降非常明显。我个人最大的感受是所谓“架构演进”很多时候不是引入一个多惊艳的新技术而是把一个被拆碎的基础能力收敛回它本该在的位置。统一令牌链路只是这个思路在登录认证领域的一个落地案例后面业务系统再扩张不管是新增客户端还是开放第三方接入我们都不用再改认证核心了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑