基于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核心设计思想

  1. 唯一认证入口:全局只保留授权中心一个登录页,所有子系统未登录全部跳转统一登录地址。

  2. 全局会话共享:登录成功后会话保存在授权中心,所有子系统共享该会话状态。

  3. 令牌全域通行:授权中心下发JWT令牌,所有微服务统一校验令牌,认证通过直接放行。

  4. 统一会话销毁:退出登录时清空授权中心会话+拉黑令牌,实现全域同步下线。

五、OAuth2.0-SSO 完整执行流程

本文采用安全性最高、企业通用的OAuth2.0授权码模式实现微服务SSO,覆盖未登录免登、跨服务通行、全局登出全闭环流程:

5.1 首次访问子系统(未登录状态)

  1. 用户访问任意业务微服务接口,当前无登录令牌、无全局会话;

  2. 业务系统自动跳转至统一SSO授权中心登录页,携带自身回调地址;

  3. 用户输入账号密码完成统一认证;

  4. 授权中心认证成功,生成一次性临时授权码,跳转回业务系统回调地址;

  5. 业务服务服务端请求授权中心,兑换全局JWT令牌(AccessToken+RefreshToken);

  6. 本地存储令牌,完成当前服务登录。

5.2 跨服务免登流程(SSO核心能力)

  1. 用户跳转访问其他未登录业务微服务;

  2. 新业务服务检测无本地令牌,自动跳转SSO授权中心;

  3. 授权中心检测全局会话已存在,无需重复校验账号密码;

  4. 直接返回授权码,业务服务兑换令牌自动登录;

  5. 实现一次登录、所有微服务全域通行

5.3 全局统一登出流程

  1. 用户在任意微服务触发退出登录;

  2. 请求同步至SSO授权中心,清空全局会话信息;

  3. 将当前有效JWT令牌加入黑名单,主动失效令牌;

  4. 所有业务服务的旧令牌全部失效,访问任意系统强制跳转登录页;

  5. 实现一处退出、全域强制重登的生产级能力。

六、实战环境说明(全程无废弃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 单点登录全域通行验证

  1. 浏览器访问 http://localhost:8081/business/user/info,未登录自动跳转统一授权登录页;

  2. 输入账号密码登录成功,正常访问8081业务接口;

  3. 直接访问 http://localhost:8082/business/user/info无需重复登录,直接通行

  4. 验证通过:实现微服务一次登录、多服务免登通行。

8.2 全局登出全域失效验证

  1. 调用任一业务服务登出接口:http://localhost:8081/logout

  2. 提示登出成功,当前令牌加入全局黑名单;

  3. 刷新8081、8082业务接口,全部鉴权失败,强制跳转登录页

  4. 验证通过:实现一处退出、所有微服务全域重登。

九、生产级优化拓展方向

  • 集群高可用:授权中心多节点集群部署,消除单点故障;

  • Redis分布式黑名单:替换内存黑名单,适配集群环境,实现分布式令牌失效;

  • RSA非对称加密:替换默认对称加密,提升JWT令牌安全性;

  • 数据库持久化客户端:替代内存客户端配置,支持动态新增业务服务;

  • 网关统一鉴权:接入SpringCloud网关,统一拦截校验,业务服务完全无感知;

  • 刷新令牌机制优化:实现令牌无感续期,提升用户体验。

十、全文总结

1、基于OAuth2.0授权码模式的SSO,是微服务架构下标准化的单点登录方案,彻底解决了多服务重复登录、会话隔离、登出不同步的行业痛点。

2、微服务SSO核心精髓为统一认证、会话共享、令牌通行、全域失效,区别于单机伪登录,完全贴合分布式多节点生产架构。

3、本文全程采用Spring Authorization Server1.5.5无废弃API,规避全网老旧过期写法,通过双业务服务实战,完整复现企业级SSO核心能力。

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

相关推荐
橘色的喵1 小时前
MergeCell 合并机制:双平台事件流去重
后端
予怀9601 小时前
ClickHouse踩坑:一个sum()引发的"数字不一致"悬案
后端
tntxia1 小时前
数据管理能力成熟度评估模型(DCMM)(软考架构师考点)
架构
橘色的喵1 小时前
ReclaimBatcher 批量回收:RT-Thread 单核与 Linux SMP 通用
后端
攻城有术2 小时前
专项攻克-springcloud及其组件
后端·spring·spring cloud
IanSkunk2 小时前
企业AI Agent生产化落地:从技术架构到实施服务的全链路分析
java·人工智能·架构
Kripath_Rion3 小时前
带你速通计算机经典论文(一):分布式系统篇
分布式·后端·架构