简介:这是一份讲解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,不包含用户身份语义 | 授权第三方应用访问资源 | 做一个“登录”之外的授权能力 |
| OIDC | OAuth2 之上加身份层 | ID Token(JWT),携带用户身份信息 | 现代应用单点登录 | 前后端分离、有移动端、新项目首选 |
我现在的选型习惯是:新项目一律走 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> </dependency>spring-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-secret:
server: 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 的内存;刷新时请求被分到了实例 B,B 的内存里没有这台机器的 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 ResponseEntity<Void> 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 本身不是一个能“装完就忘”的组件,它是一套需要持续维护的信任体系。希望帮到你。
本文还有配套的精品资源,点击获取