欢迎光临
我们一直在努力

基于OAuth2.0实现微服务SSO单点登录

前言

在微服务架构中,系统会被拆分为数十个独立业务服务,例如订单服务、用户服务、商品服务、财务服务等。如果每个服务都独立实现登录、鉴权逻辑,用户访问不同系统模块时需要反复登录,体验极差,同时权限标准混乱、安全无法统一治理。

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、该架构解耦彻底、安全可控、扩展性强,可快速拓展为集群高可用、网关统一鉴权的生产级安全架构,适配绝大多数中大型微服务平台项目。

    赞(0)
    未经允许不得转载:171主机测评 » 基于OAuth2.0实现微服务SSO单点登录
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址