前言
在微服务架构中,系统会被拆分为数十个独立业务服务,例如订单服务、用户服务、商品服务、财务服务等。如果每个服务都独立实现登录、鉴权逻辑,用户访问不同系统模块时需要反复登录,体验极差,同时权限标准混乱、安全无法统一治理。
SSO单点登录(Single Sign On) 是解决该问题的核心方案:一次登录、全域通行、多系统共享会话 。而在企业级微服务架构中,OAuth2.0授权码模式是实现标准化SSO的行业通用方案,也是目前唯一适配多服务、多端、第三方接入的安全规范。
本文将从业务背景、技术优缺点、适用场景、核心原理、完整执行流程、微服务多节点代码实战 逐层递进讲解,全程采用 SpringBoot3.5.9 + SAS1.5.5 + Security6.5.7 最新稳定版本,零废弃API、零过期配置,彻底规避网上老旧废弃的apply()写法,可直接用于学习、面试、生产落地。
一、SSO单点登录实现背景
1.1 传统多服务登录的痛点
在没有SSO单点登录的微服务体系中,普遍存在以下致命问题:
-
重复登录、体验割裂:用户跳转不同业务模块、不同子系统需要重复输入账号密码,严重影响使用体验。
-
权限体系混乱不统一:各服务独立维护登录逻辑、会话机制、权限规则,出现"各玩各的"乱象,无法统一管控。
-
会话无法共享:传统Session为单机有状态,微服务集群、多服务场景下无法共享会话,天然不支持分布式登录。
-
退出不同步:用户在一个系统退出登录,其他系统仍然保持登录状态,存在严重安全隐患。
-
维护成本极高:每个服务重复开发登录、鉴权、拦截逻辑,代码冗余、迭代缓慢、问题排查分散。
1.2 OAuth2.0+SSO方案诞生意义
为解决多服务分布式登录难题,行业统一采用 OAuth2.0授权码模式实现SSO单点登录 。核心思路为:抽离独立统一授权中心,全局只保留一套登录入口,认证完成后下发标准化JWT令牌,所有微服务统一校验令牌实现免密通行。
彻底解决多服务重复登录、会话不共享、权限散乱、退出不同步问题,是微服务、中台系统、多端平台的标准安全架构。
二、OAuth2.0-SSO 优缺点分析
2.1 核心优点
-
真正实现一次登录、全域通行:用户仅需登录一次授权中心,所有关联微服务、子系统、前端站点全部免登录访问。
-
安全等级极高:基于OAuth2.0授权码模式两步鉴权,临时授权码一次性失效、令牌服务端兑换,杜绝账号密码泄露风险。
-
架构解耦彻底:登录、认证、令牌管理、权限管控全部收拢至授权中心,业务服务无需关注安全逻辑,专注业务开发。
-
统一登出、会话同步:支持全局单点下线,一处退出、全域失效,彻底解决登录状态不一致问题。
-
多端、多系统兼容:适配Web后台、H5、小程序、APP,同时支持第三方系统接入,通用性极强。
-
无状态、支持高可用扩容:基于JWT无状态令牌,无需服务端持久化会话,天然适配微服务集群部署、弹性扩容。
2.2 局限性与缺点
-
架构复杂度提升:需要独立部署授权中心、维护令牌机制、网关拦截、缓存会话,对比单体登录架构组件更多。
-
存在核心服务依赖:授权中心为全局安全核心,单机部署存在单点故障,生产必须集群部署。
-
JWT天然缺陷需兜底:无状态令牌无法主动作废,需配合Redis黑名单实现强制下线、注销能力。
-
存在授权跳转链路:首次未登录、会话过期时存在页面跳转逻辑,链路相较于本地登录更繁琐。
三、OAuth2.0-SSO 适用场景
本方案为企业级多系统、多服务专属方案,精准适配以下场景:
-
微服务分布式架构项目,存在多个独立业务子服务、子系统
-
平台型系统,需要统一后台、多前端站点、多端统一登录
-
企业中台系统、OA、ERP、CRM等多模块集成平台
-
需要对接第三方应用、第三方登录授权的开放平台
-
需要统一登出、统一会话管控、统一权限治理的大型项目
不适配场景:单机小型项目、单一模块简单系统、无需多系统联动的极简业务。
四、OAuth2.0实现SSO核心原理
OAuth2.0实现SSO单点登录的核心本质:统一认证入口 + 全局会话共享 + 标准化令牌通行。区别于传统登录,SSO并不会在每个业务服务保存会话,而是将所有认证能力、会话状态、令牌管理统一收拢至授权中心,业务服务全部依赖授权中心结果放行,从而实现跨服务免密登录。
4.1 OAuth2标准四角色
-
资源所有者(用户):最终访问系统、授权登录的终端用户。
-
客户端(业务子系统/微服务):需要免登录访问的业务服务,本身不处理登录逻辑。
-
授权服务器(SSO核心):独立部署的统一授权中心,唯一负责登录认证、生成令牌、会话管理、权限分发。
-
资源服务器(业务接口):存放业务数据、提供接口能力的微服务。
4.2 SSO核心设计思想
-
唯一认证入口:全局只保留授权中心一个登录页,所有子系统未登录全部跳转统一登录地址。
-
全局会话共享:登录成功后会话保存在授权中心,所有子系统共享该会话状态。
-
令牌全域通行:授权中心下发JWT令牌,所有微服务统一校验令牌,认证通过直接放行。
-
统一会话销毁:退出登录时清空授权中心会话+拉黑令牌,实现全域同步下线。
五、OAuth2.0-SSO 完整执行流程
本文采用安全性最高、企业通用的OAuth2.0授权码模式实现微服务SSO,覆盖未登录免登、跨服务通行、全局登出全闭环流程:
5.1 首次访问子系统(未登录状态)
-
用户访问任意业务微服务接口,当前无登录令牌、无全局会话;
-
业务系统自动跳转至统一SSO授权中心登录页,携带自身回调地址;
-
用户输入账号密码完成统一认证;
-
授权中心认证成功,生成一次性临时授权码,跳转回业务系统回调地址;
-
业务服务服务端请求授权中心,兑换全局JWT令牌(AccessToken+RefreshToken);
-
本地存储令牌,完成当前服务登录。
5.2 跨服务免登流程(SSO核心能力)
-
用户跳转访问其他未登录业务微服务;
-
新业务服务检测无本地令牌,自动跳转SSO授权中心;
-
授权中心检测全局会话已存在,无需重复校验账号密码;
-
直接返回授权码,业务服务兑换令牌自动登录;
-
实现一次登录、所有微服务全域通行。
5.3 全局统一登出流程
-
用户在任意微服务触发退出登录;
-
请求同步至SSO授权中心,清空全局会话信息;
-
将当前有效JWT令牌加入黑名单,主动失效令牌;
-
所有业务服务的旧令牌全部失效,访问任意系统强制跳转登录页;
-
实现一处退出、全域强制重登的生产级能力。
六、实战环境说明(全程无废弃API)
6.1 技术版本选型
本次实战采用目前最稳定、无任何废弃API的技术栈,规避全网老旧过期写法,适配SpringBoot3新版规范:
-
JDK:17+
-
SpringBoot:3.5.9
-
SpringSecurity:6.5.7
-
Spring Authorization Server:1.5.5(官方终版,无废弃配置)
6.2 核心避坑点
全网多数SSO教程存在过期废弃代码,本文全程规避:
-
❌ 废弃旧写法:
http.apply(OAuth2AuthorizationServerConfigurer)链式装配 -
✅ 新版合规写法:
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)静态适配 -
❌ 废弃旧配置:传统Token过期链式配置、过期授权注解
-
✅ 新版合规配置:统一使用TokenSettings构建器配置令牌时效
七、微服务集群SSO实战(多服务联动|单点登录+全局登出)
本次实战摒弃单机伪SSO演示,搭建真实微服务多节点架构 :1个统一授权中心 + 2个独立业务微服务,端口、服务完全隔离,完美模拟生产环境,实现核心能力:一次登录多服务通行、任意服务退出全域重登。
7.1 实战架构拓扑
-
SSO授权中心(8080):全局唯一认证入口,负责登录校验、令牌生成、会话管理、全局登出
-
业务服务A(8081):模拟订单/商品业务,无登录鉴权逻辑,完全依赖授权中心
-
业务服务B(8082):模拟用户/财务业务,无登录鉴权逻辑,完全依赖授权中心
7.2 全局公共依赖(所有服务通用)
三个服务统一使用以下依赖,版本统一、规避兼容问题:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.9</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<sas.version>1.5.5</sas.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>${sas.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 如果需要再退出后通知对应的客户端下线的需要引入该依赖包,其他不需要 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
</dependencies>
</project>
7.3 授权中心核心实现(8080)
7.3.1 全局安全配置
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
@EnableWebSecurity
public class GlobalServiceConfig {
//系统默认的是bcrypt,如果业务模块使用明文且调试用的非bcrypt,需要注册delega格式的密码加解密方法,默认返回bcr的话会因为密码加密方式不一致导致在通过code获取accesstoken时报密码类型不对,导致获取token失败,登录报错
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt",new BCryptPasswordEncoder());
// noop编码器,明文模式,仅用于调试
encoders.put("noop", NoOpPasswordEncoder.getInstance());
// 代理编码器,支持{noop}、{bcrypt}前缀
return new DelegatingPasswordEncoder("bcrypt",encoders);
}
@Bean
@Order(3)
public SecurityFilterChain globalSecurityFilterChain(HttpSecurity http) throws Exception {
// 不全局关闭csrf,表单登录保留csrf防护;只把业务接口放行csrf
http.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll()
.anyRequest().authenticated()
)
// spring内置表单登录,/oauth2/authorize无session时跳转到这里
.formLogin(Customizer.withDefaults());
return http.build();
}
//使用浏览器开发者模式时,浏览器会发起该请求,经过拦截器后会跳转到兜底的拦截器,然后因为之前鉴权的token设置,报401错误,需要忽略该请求
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers("/.well-known/appspecific/**");
}
//注入一个默认的用户用于调试使用,需要真实用户可以自己写一个customeUserDetailService,实现loadUserByUsername从数据库中读取真实的用户信息
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails admin = User.withUsername("test")
.password(passwordEncoder.encode("123456"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin);
}
}
7.3.2 授权服务核心配置(无废弃终版)
import jakarta.websocket.EndpointConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.CsrfDsl;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatchers;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Configuration
@EnableWebSecurity
public class SsoAuthorizationServerConfig {
@Bean
@Order(0)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
//不允许手动 new 对象,新版本会出现部分组件没有初始化,RegisteredClientRepository读取异常,报client_id参数错误。
OAuth2AuthorizationServerConfigurer configurer = OAuth2AuthorizationServerConfigurer.authorizationServer();
//如果传递的scope范围包含openId,强制启动oidc配置
configurer// 必须开启OIDC配置
.oidc(Customizer.withDefaults());
http.with(configurer, Customizer.withDefaults());
// 获取配置器实例,拿到端点匹配器
assert configurer != null;
RequestMatcher endpointsMatcher = configurer.getEndpointsMatcher();
// 自动读取所有客户端,拼接标准back-channel地址推送下线通知
http.oidcLogout(oidcLogout -> oidcLogout.backChannel(Customizer.withDefaults()));
//登出的相关操作配置
http.logout(logout -> logout
.logoutUrl("/logout")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/login")
);
http.securityMatcher(endpointsMatcher)
// oauth全部端点忽略csrf,token、refresh请求不会因为csrf拦截
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.authorizeHttpRequests(registry -> registry.anyRequest().authenticated())
// 关键:token接口是客户端机器调用,未认证不要跳转登录,返回401;仅授权码端点才跳转登录
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
RequestMatchers.anyOf(new AntPathRequestMatcher("/oauth2/token"))
))
//针对登录放行
.formLogin(form -> form.loginPage("/login").permitAll())
// 新增:未登录时强制跳转登录地址
.exceptionHandling(ex -> ex.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")));
return http.build();
}
}
7.3.3 JWT自定义载荷(多服务共享用户信息)
package com.sso.config;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class SsoJwtTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
@Override
public void customize(JwtEncodingContext context) {
Authentication authentication = context.getPrincipal();
String username = authentication.getName();
Map<String, Object> ssoClaims = new HashMap<>();
ssoClaims.put("username", username);
ssoClaims.put("userId", 10001L);
ssoClaims.put("nickName", "微服务SSO全局用户");
ssoClaims.put("role", "ADMIN");
ssoClaims.put("ssoGlobal", "true");
context.getClaims().claims(claims -> claims.putAll(ssoClaims));
}
}
7.3.4 客户端注册(用于存储接入的业务客户端,才支持登出回调、back-channel 地址配置)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import java.time.Duration;
@Configuration
public class ClientConfig {
// 1. 授权中心管理业务客户端:RegisteredClientRepository(存储接入的RP服务)
@Bean
//调低优先级,避免被其他配置覆盖
@Order(-1)
public RegisteredClientRepository registeredClientRepository() {
//token设定
TokenSettings tokenSettings = TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofSeconds(3600))
.refreshTokenTimeToLive(Duration.ofDays(7))
.reuseRefreshTokens(false)
.build();
RegisteredClient registeredClientA = RegisteredClient.withId("client-001")
.clientId("sso-client")
.clientSecret("{noop}123456")
//业务服务请求鉴权必须开启该模式
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://localhost:8081/login/oauth2/code/sso-client")
// 关键:授权服务登出完成后跳转地址
.postLogoutRedirectUri("http://localhost:8081/login")
.tokenSettings(tokenSettings)
.scope("sso:all")
.scope("openid")
.scope("profile")
.build();
//如果需要多个客户端就多注册几个
return new InMemoryRegisteredClientRepository(registeredClientA);
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
// 传入空集合,合法构造,不会报空
// 构造一个虚拟占位ClientRegistration,仅用于满足构造器非空校验
ClientRegistration dummyClient = ClientRegistration.withRegistrationId("dummy")
.clientId("dummy")
.clientSecret("dummy")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://dummy")
.tokenUri("http://dummy/oauth2/token")
.authorizationUri("http://dummy/oauth2/authorize")
.build();
return new InMemoryClientRegistrationRepository(dummyClient);
}
}
7.3.5 授权中心启动类与配置文件
启动类
package com.sso;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SsoAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(SsoAuthServerApplication.class, args);
}
}
application.yml
server:
port: 8080
spring:
application:
name: sso-auth-center
7.4 业务微服务通用实现(8081/8082)
两个业务服务无任何登录逻辑,仅做令牌校验,配置完全一致,仅端口区分,实现无感化鉴权。
7.4.1 业务服务安全配置
package com.cfm.spring_oauth2_servicea.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.savedrequest.CookieRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.stereotype.Component;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import static org.springframework.security.web.util.matcher.RequestMatchers.*;
@Configuration
public class SecurityConfig {
// 注入全局缓存Bean
private final RequestCache cookieRequestRepo;
public SecurityConfig(RequestCache cookieRequestCache) {
this.cookieRequestRepo = cookieRequestCache;
}
@Bean
@Order(3)
public SecurityFilterChain filterChain(HttpSecurity http,// 必须注入该Bean,OidcClientInitiatedLogoutSuccessHandler依赖
ClientRegistrationRepository clientRegistrationRepository) throws Exception {
// Cookie存储授权请求,彻底脱离HttpSession,跨端口请求会导致session丢失,在授权中心获取到授权码code回来校验后因为sessionid丢失导致无法找到之前历史的请求session信息,无法匹配授权请求,抛出错误
HttpCookieOAuth2AuthorizationRequestRepository cookieRepo = new HttpCookieOAuth2AuthorizationRequestRepository();
//不要用 lambda 做排除式匹配,Spring OAuth2 客户端回调非常依赖securityMatcher精准路径匹配,lambda 包装 request 会干扰 session 存储。丢失session会导致后续做比较时出现获取session对象为空,匹配失败,异常
// http.securityMatcher("/login/**", "/login/oauth2/code/**","/business/**");
//不推荐大范围的取反匹配,会导致session丢失
// // 构建匹配器:匹配 /api/**
// AntPathRequestMatcher apiAntMatcher = new AntPathRequestMatcher("/api/**");
// // 取反:排除/api/**,匹配其余全部请求
// NegatedRequestMatcher exceptApiMatcher = new NegatedRequestMatcher(apiAntMatcher);
// http.securityMatcher(exceptApiMatcher);
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/.well-known/**")).permitAll()
// api接口 jwt鉴权
.requestMatchers(new AntPathRequestMatcher("/api/**")).authenticated()
//放行后端登出接口(关键!否则授权服务后台调用401无权限)
.requestMatchers(new AntPathRequestMatcher("/logout/connect/**")).permitAll()
// 页面、登录、回调全部需要登录
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> {
oauth2
// 替换存储实现
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestRepository(cookieRepo))
.successHandler((request,response,authentication)->{
// CookieRequestCache专用读取方式,不能直接getSession拿属性
System.out.println("cookieRequestCache");
SavedRequest savedReq = cookieRequestRepo.getRequest(request, response);
String targetUrl = "/";
if (savedReq != null) {
targetUrl = savedReq.getRedirectUrl();
}
response.sendRedirect(targetUrl);
});});
// ========== 单点登出核心配置 ==========
http.logout(logout -> logout
.logoutUrl("/logout") // 前端页面退出访问地址 <a href="/logout">退出登录</a>
.clearAuthentication(true) // 清空本地认证信息
.invalidateHttpSession(true) // 销毁本地会话
// 清理OAuth授权缓存Cookie、会话Cookie、自定义缓存Cookie
.deleteCookies("JSESSIONID", "SPRING_SECURITY_OAUTH2_AUTHORIZATION_REQUEST", "SEC_SAVED_REQ")
// 官方标准OIDC单点登出处理器,自动读取授权服务元数据
// .logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository))
//下列做了优化处理
.logoutSuccessHandler((request, response, authentication) -> {
// 未登录直接跳登录页,避免空指针
if (authentication == null || !(authentication.getPrincipal() instanceof OidcUser)) {
response.sendRedirect("/login");
return;
}
OidcClientInitiatedLogoutSuccessHandler handler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
handler.onLogoutSuccess(request, response, authentication);
})
);
// ========== 接收授权服务BackChannel后端登出通知 ==========
http.oidcLogout(oidcLogout -> oidcLogout
.backChannel(Customizer.withDefaults()) // 使用框架默认地址 /logout/connect/sso-client
);
return http.build();
}
//资源服务的拦截器与oauth的拦截器分开写,避免因为拦截器冲突导致授权跳转异常
/**
* 链1:【资源服务器】API接口,接收Bearer Token,无页面跳转
* 例如:/api/** 接口,外部携带token调用
*/
@Bean
@Order(1)
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") //只匹配API接口路径
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(resource -> resource.jwt(Customizer.withDefaults()))
.csrf(csrf -> csrf.disable()); //api接口关闭csrf
return http.build();
}
}
缓存设置全局统一的bean,直接new会导致丢失上下文信息
import jakarta.servlet.http.Cookie;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.savedrequest.CookieRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
@Configuration
public class WebCacheConfig {
@Bean
public RequestCache cookieRequestCache() {
CookieRequestCache cookieCache = new CookieRequestCache();
cookieCache.setCookieCustomizer((Cookie cookie) -> {
//注释掉的为新版本的参数,当前使用的版本无该方法
// cookie.setName("SSO_REDIRECT_URL");
cookie.setSecure(false);
// cookie.setSameSite("Lax");
cookie.setHttpOnly(true);
cookie.setMaxAge(3600);
});
return cookieCache;
}
}
使用cookie存储信息需要手动编写对应的repository
package com.cfm.spring_oauth2_servicea.config;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.util.SerializationUtils;
import java.util.Base64;
/**
* Cookie存储OAuth2授权请求,无Session状态,替代HttpSessionOAuth2AuthorizationRequestRepository
*/
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE = "oauth2_auth_req";
private static final int COOKIE_MAX_AGE_SECONDS = 6 * 60; //6分钟有效期
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Cookie cookie = getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE);
if (cookie == null) {
return null;
}
byte[] decode = Base64.getDecoder().decode(cookie.getValue());
return (OAuth2AuthorizationRequest) SerializationUtils.deserialize(decode);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequest(request, response);
return;
}
byte[] bytes = SerializationUtils.serialize(authorizationRequest);
String value = Base64.getEncoder().encodeToString(bytes);
Cookie cookie = new Cookie(OAUTH2_AUTHORIZATION_REQUEST_COOKIE, value);
cookie.setHttpOnly(true);
cookie.setMaxAge(COOKIE_MAX_AGE_SECONDS);
cookie.setPath("/");
response.addCookie(cookie);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
OAuth2AuthorizationRequest req = loadAuthorizationRequest(request);
clearCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE);
return req;
}
private Cookie getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
for (Cookie c : cookies) {
if (name.equals(c.getName())) {
return c;
}
}
return null;
}
private void clearCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
}
}
7.4.2 SSO用户信息解析工具类
package com.business.util;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
public class SsoUserUtil {
public static Long getUserId(Authentication authentication) {
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
return null;
}
return jwt.getClaim("userId");
}
public static String getUsername(Authentication authentication) {
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
return "";
}
return jwt.getClaim("username");
}
public static String getNickName(Authentication authentication) {
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
return "";
}
return jwt.getClaim("nickName");
}
}
7.4.3 业务测试接口
package com.business.controller;
import com.business.util.SsoUserUtil;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/business")
public class BusinessController {
@GetMapping("/user/info")
public Map<String, Object> userInfo(Authentication authentication) {
Map<String, Object> result = new HashMap<>();
result.put("用户ID", SsoUserUtil.getUserId(authentication));
result.put("登录账号", SsoUserUtil.getUsername(authentication));
result.put("用户昵称", SsoUserUtil.getNickName(authentication));
result.put("登录方式", "OAuth2.0全局SSO单点登录");
return result;
}
}
7.4.4 服务配置与启动类
业务服务A(8081)yml配置
server:
port: 8081
# 解决回调时浏览器不带 JSESSIONID。
servlet:
session:
cookie:
# same-site: none
# secure: false # http环境关闭,https开启true
same-site: lax
http-only: true
spring:
application:
name: spring_oauth2_servicea
security:
oauth2:
# --------- 用于浏览器页面登录:oauth2Login客户端(会话、跳转授权中心) ---------
client:
registration:
sso-client:
client-id: sso-client
client-secret: 123456
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-authentication-method: client_secret_basic # 新增,强制Basic认证
scope:
- sso:all
- openid
- profile
provider:
sso-client:
authorization-uri: http://localhost:8080/oauth2/authorize
token-uri: http://localhost:8080/oauth2/token
jwk-set-uri: http://localhost:8080/oauth2/jwks
# 新增OIDC用户信息端点
user-info-uri: http://localhost:8080/userinfo
# OIDC标准唯一用户标识
user-name-attribute: sub
# --------- 用于接口鉴权:资源服务器,接收Bearer Token调用 ---------
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/oauth2/jwks
业务服务B(8082)yml配置
server:
port: 8082
# 解决回调时浏览器不带 JSESSIONID。
servlet:
session:
cookie:
# same-site: none
# secure: false # http环境关闭,https开启true
same-site: lax
http-only: true
spring:
application:
name: spring_oauth2_serviceb
security:
oauth2:
# --------- 用于浏览器页面登录:oauth2Login客户端(会话、跳转授权中心) ---------
client:
registration:
sso-client:
client-id: sso-client
client-secret: 123456
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-authentication-method: client_secret_basic # 新增,强制Basic认证
scope:
- sso:all
- openid
- profile
provider:
sso-client:
authorization-uri: http://localhost:8080/oauth2/authorize
token-uri: http://localhost:8080/oauth2/token
jwk-set-uri: http://localhost:8080/oauth2/jwks
# 新增OIDC用户信息端点
user-info-uri: http://localhost:8080/userinfo
# OIDC标准唯一用户标识
user-name-attribute: sub
# --------- 用于接口鉴权:资源服务器,接收Bearer Token调用 ---------
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/oauth2/jwks
通用启动类
package com.business;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BusinessServiceApplication {
public static void main(String[] args) {
SpringApplication.run(BusinessServiceApplication.class, args);
}
}
八、微服务SSO核心能力测试验证
依次启动 8080授权中心、8081业务服务A、8082业务服务B,完成以下两组核心验证,贴合真实微服务SSO效果。
8.1 单点登录全域通行验证
-
浏览器访问
http://localhost:8081/business/user/info,未登录自动跳转统一授权登录页; -
输入账号密码登录成功,正常访问8081业务接口;
-
直接访问
http://localhost:8082/business/user/info,无需重复登录,直接通行; -
验证通过:实现微服务一次登录、多服务免登通行。
8.2 全局登出全域失效验证
-
调用任一业务服务登出接口:
http://localhost:8081/logout; -
提示登出成功,当前令牌加入全局黑名单;
-
刷新8081、8082业务接口,全部鉴权失败,强制跳转登录页;
-
验证通过:实现一处退出、所有微服务全域重登。
九、生产级优化拓展方向
-
集群高可用:授权中心多节点集群部署,消除单点故障;
-
Redis分布式黑名单:替换内存黑名单,适配集群环境,实现分布式令牌失效;
-
RSA非对称加密:替换默认对称加密,提升JWT令牌安全性;
-
数据库持久化客户端:替代内存客户端配置,支持动态新增业务服务;
-
网关统一鉴权:接入SpringCloud网关,统一拦截校验,业务服务完全无感知;
-
刷新令牌机制优化:实现令牌无感续期,提升用户体验。
十、全文总结
1、基于OAuth2.0授权码模式的SSO,是微服务架构下标准化的单点登录方案,彻底解决了多服务重复登录、会话隔离、登出不同步的行业痛点。
2、微服务SSO核心精髓为统一认证、会话共享、令牌通行、全域失效,区别于单机伪登录,完全贴合分布式多节点生产架构。
3、本文全程采用Spring Authorization Server1.5.5无废弃API,规避全网老旧过期写法,通过双业务服务实战,完整复现企业级SSO核心能力。
4、该架构解耦彻底、安全可控、扩展性强,可快速拓展为集群高可用、网关统一鉴权的生产级安全架构,适配绝大多数中大型微服务平台项目。