ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SSO单点登录原理与Spring Security落地实践:令牌流转与避坑指南

SSO单点登录原理与Spring Security落地实践:令牌流转与避坑指南 简介这是一份讲解SSO单点登录原理并给出Java Servlet实现参考的资源包适合正在学习Java Web认证机制、需要理解集中式身份认证流程或准备在多系统项目中落地统一登录的开发者。内容先梳理SSO运行原理认证中心、TGT凭证、Service Ticket的生成与校验、服务应用如何验票再结合Tomcat与Servlet容器讲解实现步骤包括登录页面、自定义过滤器拦截受保护URL、Session保存用户对象、Token传递方式、注销时的状态清理并提到后续可扩展CAS、OAuth2或OpenID Connect。压缩包共80个文件核心是11个Java源码和5个JSP页面另有13个XML配置、properties配置、编译后的class文件等适合对照工程结构阅读或导入开发环境运行整体仅103KB轻量方便。已有146人学习下载。通过阅读能掌握TGT/Service Ticket协作机制获得基于Servlet Filter的统一认证代码骨架以及多应用间共享会话的基础设计思路。1. 什么场景下你需要SSO单点登录先算清这笔账再动手当你手上有三套内部系统每个系统都自己管账号密码用户每切换一次就要重新登录一次这个时候你大概已经忍无可忍了。SSO单点登录就是把这套重复劳动收编成一套机制用户只要在认证中心登录一次再访问所有接入过的系统都不需要二次输密码。它听起来像大厂基建但中小团队只要系统数量上来了同样躲不开这个问题——session各管各的、密码策略没法统一、还有人把账号写在便签纸上。这篇我按自己的落地经验先把SSO的运作原理讲明白再给你一套用 Java 生态能跑通的最小实现最后把实际接入时最容易翻车的地方挑出来逐个给排查思路。适合还在选型阶段的团队也适合已经在接 SSO 但被回调地址和令牌问题反复折腾的人。2. 把SSO的原理拆开会话共享、令牌流转与四种协议选型2.1 所有SSO都在解决同一个问题让多个系统相信同一个“登录事实”不搞SSO的时候每个系统各自维护一份 session。你在 A 系统登录了A 的 session 只存在 A 的内存里B 系统完全不知道这件事。所以切到 B 还得重新登录。SSO 的核心思路是把“登录状态”从各个业务系统里抽出来统一放到一个认证中心去维护。业务系统不再自己验证账号密码而是验证认证中心签发的一张“凭证”。这张凭证在不同协议里有不同叫法——Ticket、Token、ID Token本质都是“介绍信”的概念。流程上分两类东西要分清一个是认证中心自己持有的会话另一个是发给各业务系统的令牌。用户在认证中心登录成功后认证中心记录会话状态同时给用户发一张令牌。用户拿着令牌去访问业务系统业务系统不查数据库验证账号只验令牌签名的真伪和有效期验过就放行。这就是所谓“一次登录处处信任”。这个“信任”不是白来的。业务系统要能验证令牌是认证中心签发的而不是用户自己伪造的。所以令牌要么是带签名的 JWT要么是需要回认证中心校验一次的 Ticket。这个机制决定了后面协议选型怎么走。2.2 令牌怎么流转一次完整的SSO登录请求从头走一遍把一个典型流程拆开看你会对 SSO 的运作有体感。假设用户访问系统 A系统 A 还没收到任何凭证系统 A 发现没有会话把请求重定向到认证中心的登录页同时带上一个回调地址告诉认证中心“验证通过后把人送回我这里”。用户在认证中心的登录页输入账号密码认证中心验证通过给用户种下认证中心的会话同时生成一个一次性授权码跟着重定向回到系统 A 的回调地址。系统 A 拿到这个授权码再通过后端请求去向认证中心换取真正的令牌。换到令牌后系统 A 自己验证令牌签名和有效期然后建立本地会话放行用户。整个过程中有两点容易踩坑。第一授权码是一次性的而且有效期非常短通常只有几十秒拿到授权码之后必须立刻换令牌绝对不能在前端停留。第二redirect_uri 必须精确匹配——认证中心里登记的回调地址和业务系统实际使用的回调地址任何一个字符不一致授权流程就会直接中断。后面避坑章节我会针对这些展开说。2.3 四种主流协议对比CAS、SAML、OAuth2、OIDC怎么选协议决定了令牌形态和交互方式。我接触到的团队大多数在这四个里面选CAS、SAML、OAuth2、OIDC。放在一起对比最直观。协议出身背景令牌形态典型使用场景适合谁CAS耶鲁大学开源方案Ticket短时一次性票据企业内部老系统、Java 技术栈老系统多、想低成本改造的团队SAML企业级身份联邦标准XML 断言较重跨公司身份互通、AD FS / 企业微信 / 钉钉对接需要和外部企业身份体系打通的场景OAuth2授权框架Access Token不包含用户身份语义授权第三方应用访问资源做一个“登录”之外的授权能力OIDCOAuth2 之上加身份层ID TokenJWT携带用户身份信息现代应用单点登录前后端分离、有移动端、新项目首选我现在的选型习惯是新项目一律走 OIDC因为它基于 OAuth2同时解决了“用户是谁”的问题如果团队里一堆老系统而且是 Java 系CAS 反而更务实因为对老系统的侵入性小SAML 通常只在对接企业级身份源的时候才接触自己从零搭 SSO 很少直接选它。还有一种常见误用是把 OAuth2 的 Access Token 当身份凭证用——Access Token 是给接口授权用的里面不一定携带用户身份OIDC 里的 ID Token 才是身份凭证。这个区分不清后面排查问题会很痛苦。2.4 会话与令牌的有效期边界为什么令牌不能设太长也不能设太短很多人把 SSO 出问题归咎于协议最后发现是有效期没理清。认证中心会话和业务系统令牌是两套独立生命周期。认证中心会话过期了用户再访问业务系统时业务系统手里的令牌还没过期用户依然能用。反过来令牌过期了认证中心会话还在用户会被自动引导到认证中心重新发一个令牌不需要重新输密码——这就是“滑动认证”的体验基础。有效期设置要看安全等级。内部管理系统Access Token 设 30 分钟到 2 小时都常见。对外暴露的系统建议缩短到 10 到 15 分钟同时配 Refresh Token 做续期。这里没有万能参数核心原则是令牌活得过长泄漏风险变大活得太短业务系统校验次数变多体验会毛糙。我在生产环境里一般把认证中心会话设为 8 小时Access Token 设 30 分钟Refresh Token 24 小时再根据实际使用频率调。3. 用Spring Security跑通最小SSO认证服务器与两个客户端的完整配置3.1 先说清方案边界Java生态里最省事的一条路谈到 Java 单点登录的实现现在最省事的是用 Spring Security 全家桶认证中心用 Spring Authorization Server业务系统用 Spring Security 的 OAuth2 Client 模块。这套组合是 Spring 官方维护的文档全、社区活跃最关键的是它把授权码流程、JWT 签发、回调处理都封装好了不需要自己从零写重定向逻辑。老一点的团队还在用 CAS 加 Spring Security CAS 模块但新项目直接走 OIDC 更合适省掉后续迁移成本。下面这套最小方案我会用三个服务演示一个认证服务器跑在 9000 端口两个客户端各自跑在 8081 和 8082 端口。你在 8081 登录一次再访问 8082 不需要二次登录。3.2 认证服务器配置注册客户端、签发JWT、定义登录用户先搭认证服务器。Maven 依赖只需要两个核心模块我通常会再显式引入一个 JOSE 库来支持 JWT 编解码dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdcom.nimbusds/groupId artifactIdnimbus-jose-jwt/artifactId /dependencyspring-boot-starter-oauth2-authorization-server是核心负责授权码流程、令牌分发和 JWK 管理。nimbus-jose-jwt是 JOSE 协议实现JWT 签名和解析依赖它。然后是认证服务器的核心配置类。这里省略生产级细节先跑通最小闭环Configuration EnableWebSecurity public class AuthServerConfig { Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient appA RegisteredClient.withId(app-a) .clientId(app-a) .clientSecret({noop}secret-a) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://127.0.0.1:8081/login/oauth2/code/app-a) .scope(openid, profile) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(false) .build()) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofHours(24)) .build()) .build(); return new InMemoryRegisteredClientRepository(appA); } Bean Order(1) public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/oauth2/**, /login, /error) .authorizeHttpRequests(auth - auth.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } Bean Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth.anyRequest().permitAll()); return http.build(); } Bean public UserDetailsService userDetailsService() { UserDetails admin User.withUsername(admin) .password({noop}123456) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(admin); } }这段配置有四个关键点。RegisteredClient就是业务系统在认证中心里的“身份档案”clientId和clientSecret相当于业务系统的账号密码redirectUri必须和业务系统实际回调地址完全一致否则授权码发不出去。AuthorizationGrantType.AUTHORIZATION_CODE是 OIDC 最常用的授权码模式适合需要用户交互的登录场景。requireAuthorizationConsent(false)关掉了授权确认页企业内网用问题不大对外系统建议开启让用户看到“这个应用将获取你的哪些信息”。最后UserDetailsService是用户存储。示例里用了内存用户实际生产换成 JDBC 或对接企业目录服务都可以这只是登录页校验账号密码的入口。3.3 客户端接入配置让业务系统认认证中心签发的令牌认证服务器准备好之后处理两个客户端的接入。先看客户端的配置这里以 8081 端口为例8082 只需换端口号、client-id 和 client-secretserver: port: 8081 spring: application: name: app-a security: oauth2: client: registration: app-a: client-id: app-a client-secret: secret-a authorization-grant-type: authorization_code redirect-uri: {baseUrl}/login/oauth2/code/{registrationId} scope: - openid - profile provider: app-a: issuer-uri: http://127.0.0.1:9000issuer-uri是客户端的“灯塔”告诉 Spring Security 认证中心在哪它会自动从这个地址拉取令牌端点、JWK 公钥等信息。redirect-uri用了占位符写法{baseUrl}会替换成客户端自己的地址{registrationId}替换成app-a最终拼出来的地址必须和认证服务器里登记的完全一致。scope里的openid是 OIDC 协议必须的profile用来请求用户基本信息。客户端的安全配置比想象中简单Configuration public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()); return http.build(); } }oauth2Login(Customizer.withDefaults())是整条链上的核心。它承担了三件事拦截未登录请求并跳转到认证中心、处理认证中心回跳的授权码、换取令牌后建立本地登录态。这段配置配好之后你不需要写任何重定向代码Spring Security 把整个 OAuth2 登录流程接管了。3.4 把三个服务拉起来联调验证一次登录三个站点通吃启动认证服务器和两个客户端然后打开浏览器访问http://127.0.0.1:8081。正常情况下请求会被重定向到http://127.0.0.1:9000/login登录页输入示例里的admin / 123456登录成功后会跳回 8081 并正常显示页面。这时再新开一个标签页访问http://127.0.0.1:8082注意必须是新标签页而不是新窗口你会发现自己已经处于登录状态不需要重新输入账号密码。如果你看到 8082 仍然要求登录先别急着怀疑代码。优先检查两点8082 的client-id是否在认证服务器里也登记了8082 的issuer-uri是否指向 9000 端口。这套最小闭环里两个客户端必须都在认证服务器有对应的RegisteredClient注册记录我给的示例里只注册了app-a你要再补一个app-b。我自己的习惯是先跑通这套最小闭环再考虑 JWT 密钥持久化。因为示例里没配 JWK 数据源Spring Authorization Server 每次重启都会生成新的 RSA 密钥对之前签发的令牌会全部失效。本地调试没问题生产必须把密钥对固化下来否则一次重启就会造成大规模重新登录事故。4. SSO落地避坑五个高频翻车现场与排查思路4.1 回调地址永远配不对redirect_uri mismatch现象用户跳转到认证中心登录之后页面报错错误信息里通常带invalid redirect_uri或者redirect_uri mismatch。原因这是我在现场遇到最多的一个问题。认证中心里RegisteredClient登记的redirectUri和客户端application.yml里的redirect-uri存在一个字符的偏差。常见偏差是端口号不一致、路径层级差一个/、或者http与https混用。有的团队还会在认证中心登记了localhost但请求实际用的是127.0.0.1这两个在回调校验里不是同一个东西。解决把两端配置复制出来对比逐字符看。客户端用占位符{baseUrl}/login/oauth2/code/{registrationId}是相对安全的写法因为占位符会自动适配当前请求的地址和协议。尽量别在认证中心侧手写死调用方的回调地址能少踩一多半的坑。4.2 授权码重复使用后端直接抛异常现象用户点了浏览器后退按钮或者前端重复提交了回调请求后端报invalid_grant或authorization code already used。原因授权码是一次性的设计上就是“用一次就销毁”。用户在认证中心登录成功后授权码被拼在重定向 URL 里回跳到客户端如果这个回跳请求被浏览器刷新、后退或者前端重复触发第二次带同一个授权码再去换令牌认证中心必然拒绝。解决客户端内置的 OAuth2 Login 模块会自动缓存已用授权码正常流程不会出问题。真正需要警惕的是手动拼接授权码流程比如你没有用 Spring Security 的oauth2Login而是自己在 Controller 里处理回调。这种情况要在拿到授权码那一刻立即换令牌换完就释放掉引用同时在页面端做重定向把带着code参数的 URL 从浏览器地址栏里清掉。还有一种做法是换完令牌后跳转到一个干净地址避免用户刷新触发重放。4.3 跨域场景下登录状态丢失用户在系统间跳来跳去反复登录现象用户在 A 系统登录成功跳去 B 系统时又被踢回认证中心明明认证中心的会话还在但 B 系统就是拿不到登录态。原因这里要分清“认证中心会话”和“本地会话”。客户端拿到令牌后会建立自己的本地会话默认存在客户端自己的 session 里。如果 A 和 B 域名不同认证中心的会话 Cookie 只能覆盖认证中心域名而 A 和 B 各自持有的令牌如果因为某种原因没有保存到可共享的介质就会导致 B 系统看起来“没登录”。解决首选方案是让客户端无状态化——拿到令牌后不依赖本地 session每次请求都主动携带令牌把令牌校验逻辑做成过滤器。如果团队短期内改不动业务系统次选方案是引入 Spring Session 加 Redis把多个客户端的会话统一存到 Redis 里。但要注意这个方案会让所有客户端都依赖 Redis 的高可用Redis 一挂所有系统集体掉线。4.4 负载均衡部署下用户刚登录完刷新一下就掉线现象客户端有多个实例部署在 Nginx 后面用户登录成功后一刷新又跳回认证中心。原因客户端默认的本地 session 存在单台实例的内存里。负载均衡把第一次请求分到了实例 A登录态写进了实例 A 的内存刷新时请求被分到了实例 BB 的内存里没有这台机器的 session于是判定未登录。这是一线踩得最多的部署坑特征是登录后第一次访问正常后续随机掉线。解决两个方向。最简单的做法是在 Nginx 层做 IP 哈希或按用户维度做会话粘滞确保同一个用户的请求总是落到同一台实例。但这个做法有副作用实例扩缩容时会把存量用户踢下线。更推荐的做法是彻底去 session令牌放前端后端每次请求验 JWT这样任何实例都能处理任何用户的请求。注意这里验 JWT 需要引入 JWT 解码过滤器把 Bearer Token 解析成登录态逻辑上等价于手动实现了无状态 SSO。4.5 用户禁用账号后已签发的令牌依然能访问所有系统现象用户被管理员封禁了但他手里的令牌在过期之前依然畅通无阻所有接入系统都放行。原因令牌是离线校验的。业务系统验证 JWT 签名只要签名合法、有效期没过它就认为是有效凭证根本不会回认证中心查“这个用户现在还在不在”。这是 JWT 方案的固有特点也是它高性能的原因——代价就是没办法即时吊销。解决把 Access Token 的有效期缩短比如内部系统控制在 15 到 30 分钟这样封禁生效的延迟最多 30 分钟关键系统在敏感操作上再叠加一层本地用户状态校验。如果是真的要全局立刻生效那就得引入令牌吊销机制比如在认证中心维护一个黑名单业务系统每次校验时去比对。这个方案会牺牲离线校验的速度生产上要根据安全等级权衡。5. 把SSO做成能用的系统登出、令牌续期与一份验证清单5.1 单点登出SLO的成熟做法很多人做完登录就以为结束了漏了登出。普通登出只销毁认证中心会话用户在各业务系统的本地会话全部残留。更麻烦的是令牌本身还是有效的别人捡到仍能访问。成熟的做法是 OpenID Connect 的 Back-Channel Logout用户在认证中心点登出认证中心向所有已登录的客户端注册的后端登出端点发一条 POST 通知各客户端收到后销毁本地会话再把令牌标记失效。自己实现时每个客户端都要暴露一个登出回调端点我在 Spring Security 里会这样处理RestController public class LogoutController { PostMapping(/logout/back-channel) public ResponseEntityVoid backChannelLogout(RequestBody LogoutToken logoutToken) { // 先校验登出令牌的签名和 issuer防止伪造 // 再根据 sub 字段找到当前用户的本地会话并销毁 return ResponseEntity.noContent().build(); } }这个端点不要暴露在公网只接受认证中心服务器到服务器的调用。生产里还要考虑通知失败的重试机制认证中心要记录哪些客户端通知失败在后台做补偿。5.2 令牌过期策略与滑动续期体验和安全都不翻车我一般把过期策略分三层。认证中心会话最长8 小时到 24 小时决定用户多久需要重新输一次密码。Access Token 最短10 到 30 分钟决定业务系统多久重新验一次令牌。Refresh Token 折中24 小时到 7 天用于 Access Token 过期后静默续期。滑动续期是另一个实用技巧用户每次带着有效 Refresh Token 来换新令牌就把 Refresh Token 的过期时间往后推一段时间这样高频用户不需要频繁重新登录低频用户到期后被踢回认证中心重新认证。这个参数建议单独给一条配置方便运维调整。5.3 一份能直接照抄的验证清单SSO 上线之前我会按这份清单过一遍。先用浏览器走一遍正向流程未登录访问客户端资源确认跳转认证中心登录成功后跳回客户端新标签页访问另一个客户端确认免登。再验证反向流程认证中心登出后访问所有客户端确认都要重新登录。然后单独压低 Access Token 有效期到 1 分钟等令牌过期后确认客户端能通过 Refresh Token 静默续期不会把用户弹回登录页。最后做一次密钥轮换演练换掉 JWK 后确认旧令牌失效业务系统报错信息明确而不是直接把请求打挂。回想我做过最痛的 SSO 事故就是登出通知没做全。用户改了密码旧系统的会话还能继续用总部被安全审计点名。后来我把所有客户端的登出端点统一登记到一张表里每次上线都按清单过一遍再没出过同类事。SSO 本身不是一个能“装完就忘”的组件它是一套需要持续维护的信任体系。希望帮到你。本文还有配套的精品资源点击获取
返回列表