在微服务大行其道的今天,提到 SSO(单点登录)和 OAuth2.0,标准的教科书架构总是教我们:

  • 搞一台单独的服务器跑统一身份认证中心(IdP,比如挂在 9000 端口)。
  • 搞 N 台服务器跑业务客户端(SP,比如挂在 8080 端口)。

但现实往往很骨感: 老板只批了一台 2 核 4G 的云服务器,或者你的独立开发项目才刚刚起步。为了一个所谓的“标准架构”,强行起两个 Spring Boot 进程?内存直接拉满,简直是杀鸡用牛刀。

那么问题来了:在资源极度有限的情况下,我们能不能把“认证中心(IdP)”和“业务系统(SP)”塞进同一个 8080 端口里,同时还能保留完美的 OAuth2.0 SP-Initiated(业务系统发起)授权码模式?

答案是:绝对可以! 只要你懂怎么让你的应用优雅地“精神分裂”。


🎭 核心思想:一台服务器,三种人格

在 All-in-One 的架构下,我们的 8080 端口需要处理三种截然不同的请求。为了不让它们打架,我们需要利用 Spring Security 的 @Order 注解,为应用切分出三条独立的 SecurityFilterChain(安全过滤器链)。

这就好比在一栋楼里开了三个不同安保级别的门,各司其职:

  1. 大门 1(IdP 协议端点):专门处理 /oauth2/authorize/oauth2/token 等标准协议。这是发证机关
  2. 大门 2(表单登录端点):专门处理 /login。这是查验用户密码的地方。
  3. 大门 3(SP 业务端点):拦截所有的业务 API /api/**,以及处理回调 /login/oauth2/code/*。这是业务大厅

虽然它们在同一个进程里,但当 Vue 前端发起登录时,逻辑上依然走的是 SP-Initiated(业务系统发起) 的标准流程:业务大厅(大门 3)将用户踢给发证机关(大门 1),发证机关验明正身(大门 2)后,再把 Code 传回给业务大厅。

{"component":"LlmGeneratedComponent","props":{"height":"750px","prompt":"创建一个名为“All-in-One 单机 SSO 逻辑流转图”的交互式组件。组件旨在直观展示服务器内部的自我调用。\n\n1. 布局结构:\n   - 左侧:用户浏览器 (Vue/React)\n   - 右侧:一个巨大的虚线框代表“单体服务器 (Port 8080)”。在这个框内部,划分为两块:上方是“大门3: 业务大厅(SP)”,下方是“大门1: 认证中心(IdP)”。\n2. 交互流转(通过点击‘下一步’演示):\n   - 步骤1:浏览器发起登录 -> 访问服务器内部的【业务大厅】(/oauth2/authorization/my-app)。\n   - 步骤2:业务大厅拦截 -> 给浏览器返回 302 重定向指令。\n   - 步骤3:浏览器根据 302 -> 访问同一个服务器的【认证中心】(/oauth2/authorize)。\n   - 步骤4:认证中心 -> 返回 302 (携带 code) 给浏览器。\n   - 步骤5:浏览器带着 code -> 访问【业务大厅】的回调地址。\n   - 步骤6(核心高亮):【业务大厅】在服务器内部,发起一个 **真实的底层 HTTP 请求**,绕出一个明显的弧形箭头,重新打回同一个服务器的【认证中心】去换 Token。用警告色(红色/橙色)高亮这段“自己调自己”的路线。\n3. UI风格:科技感,重点突出“单机内部的 HTTP 闭环”。界面文字使用中文。","id":"im_0d7feffa5dc5ee4d"}}

💻 极客代码:三链合一的终极配置

把以前要写在两个工程里的代码,浓缩到一个大配置类中。注意看 @Order 的精妙运用:

@Configuration
@EnableWebSecurity
public class AllInOneSecurityConfig {

    // ==========================================
    // 人格 1:我是高贵的“统一认证中心 IdP” (Order 1)
    // ==========================================
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults()); 

        // 没登录的,全给我滚去 /login 页面
        http.exceptionHandling(exceptions -> exceptions
                .defaultAuthenticationEntryPointFor(
                        new LoginUrlAuthenticationEntryPoint("/login"),
                        new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                )
        );
        return http.build();
    }

    // ==========================================
    // 人格 2:我是无情的“账号密码检验机” (Order 2)
    // ==========================================
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/login", "/assets/**").permitAll() // 放行登录页静态资源
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults()); 
        return http.build();
    }

    // ==========================================
    // 人格 3:我是卑微的“业务系统 SP” (Order 3)
    // ==========================================
    @Bean
    @Order(3)
    public SecurityFilterChain businessClientSecurityFilterChain(HttpSecurity http) throws Exception {
        // 划重点:限制拦截范围,别把前两个的活儿抢了!
        http.securityMatcher("/api/**", "/login/oauth2/**", "/oauth2/authorization/**") 
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                // 配置自定义的 SuccessHandler,用于签发本地 JWT 并重定向回 Vue 前端
                .successHandler(new SSOAuthenticationSuccessHandler()) 
            );
        return http.build();
    }

    // 注册客户端:自己注册自己!
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient selfClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("my-internal-app")
                .clientSecret("{noop}my-secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                // 回调地址,指向自己的 8080 端口
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/my-internal-app")
                .scope(OidcScopes.OPENID)
                .build();
        return new InMemoryRegisteredClientRepository(selfClient);
    }
}

紧接着,在你的 application.yml 里,配置这个应用作为 OAuth2 客户端去连接它自己:

spring:
  security:
    oauth2:
      client:
        registration:
          my-internal-app: 
            client-id: my-internal-app
            client-secret: my-secret
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          my-internal-app:
            # 魔法降临:Issuer 指向自己的 IP 和端口
            issuer-uri: http://127.0.0.1:8080 

前端如何触发?
在 Vue/React 前端,你只需要让浏览器直接访问 Spring Security 默认的 SP 触发端点即可开启无感流转:

// 前端直接跳转,拉开单机 SSO 序幕
window.location.href = "http://127.0.0.1:8080/oauth2/authorization/my-internal-app";

💣 高能预警:细数“精神分裂”带来的 3 个深坑

代码写完跑起来,你会发现一套标准的授权码流程居然在单机上跑通了!但在沾沾自喜之前,作为架构师,你必须防范这种“左脚踩右脚上天”架构带来的三个隐患。

坑 1:自己调自己的 HTTP 死锁 (Loopback Starvation)

这是最容易在生产环境翻车的地方。
在标准 OAuth2 流程中,客户端拿到 code 后,必须发起一个 真实的底层 HTTP POST 请求 去找认证中心换 Token。
在 All-in-One 架构中,这意味着 8080 端口发出了一个 HTTP 请求,打向了它自己的 8080 端口!

  • 隐患:如果你的并发量突然上来,外面进来的请求把 Tomcat 线程池占满了。这时,处理这些请求的线程又发起了内部 HTTP 请求去换 Token,但 Tomcat 已经没有空闲线程去接收这些内部请求了。死锁诞生,服务直接假死。
  • 解法:适当调大 server.tomcat.threads.max,或者在内部换票逻辑中自定义 WebClient/RestTemplate 的超时时间,避免无限等待。

坑 2:苛刻的 Issuer URI 校验

Spring Authorization Server 有着极度的洁癖。它在校验 Token 时,会严格比对签发者(Issuer)。
如果你在 yml 里配置的 issuer-urihttp://127.0.0.1:8080,但你在前端代码里写的是 http://localhost:8080 发起登录,跑到最后一步绝对会报 Issuer mismatch(签发者不匹配)的异常。

  • 解法:保证前端发起跳转的域名、回调地址域名、YML 配置域名 三码合一。强烈建议在开发机配置 Hosts 伪造一个真实的域名(如 sso.local.com)进行测试。

坑 3:叠加态的 Session

因为是同一个应用、同一个端口,整个 OAuth2 流转过程中,IdP 人格和 SP 人格共享了同一个 JSESSIONID Cookie。
虽然这在协议层面不会阻断授权码流程,甚至能让鉴权变得极快(因为上下文都在本地内存里),但如果你使用了 Redis Session 共享,或者后续打算剥离 IdP,你会发现 Session 里的数据像毛线球一样纠缠在一起,既有认证中心的授权状态,又有业务系统的上下文。


🎯 总结:先扛起业务,再谈微服务

有代码洁癖的人可能会说,这种架构不符合微服务的奥义。

但工程学讲究的是因地制宜。通过这套配置,我们零成本获得了一个完全符合 OAuth2.0 / OIDC 协议规范的系统。

对外,前端页面依然走的是标准的 302 跳转 -> 拿 Code -> 后端换 Token 的神圣不可侵犯的安全流程。
对内,我们省下了一台独立服务器的内存开销和运维成本。

等到某天,业务爆发,你需要接入 OA 系统、ERP 系统、小程序时,你只需要把那段 @Order(1)@Order(2) 的代码剪切出去,稍微改改 YML,一个独立的高可用认证中心就剥离出来了——前端 Vue 代码甚至连一行都不用改!

这,才是架构演进的最高境界。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐