广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Spring Boot 如何整合 OpenID Connect 实现单点登录?

在微服务与多系统并存的架构中,用户每访问一个新应用就要重新输入账号密码,体验会迅速恶化。OpenID Connect(OIDC)把身份认证从业务系统剥离,由统一的身份提供方完成登录与凭证校验。Spring Boot 应用只需作为 OIDC 客户端接入,就能复用提供方的会话,形成标准的单点登录流程。

Spring Boot 如何整合 OpenID Connect 实现单点登录?

OIDC 的优势在于它不绑定任何具体厂商,只要是符合规范的 Provider 都可以接入。对于 Java 技术栈来说,Spring Security 已经内置了完整的 OIDC 客户端能力,我们不需要手写重定向、令牌交换和 JWT 校验逻辑,只需要完成少量配置即可实现企业级单点登录。

一、OIDC 单点登录的核心流程与关键角色

OIDC 建立在 OAuth 2.0 授权码模式之上。一次完整的登录包含四个主要角色:用户、客户端应用(Spring Boot 服务)、OIDC Provider(身份提供方,如 Keycloak、Auth0)以及资源服务器。虽然 OIDC 规范也支持隐式流和混合流,但服务端 Web 应用几乎都采用授权码模式,因为它能保证客户端密钥不暴露在浏览器中。

授权码流程可以拆成几个明确步骤。用户访问受保护资源时,客户端应用先检查本地会话,如果没有登录态,就重定向到 Provider 的授权端点,并在 URL 中携带 client_id、redirect_uri、scope 和 state 等参数。用户在 Provider 的登录页完成认证,Provider 生成授权码并回调客户端。客户端再拿着授权码、client_id、client_secret 向令牌端点发起请求,换取 access_token 和 id_token。这里的 id_token 是 JWT,包含 iss、sub、aud、exp、iat 等声明,客户端必须校验签名与 audience 后才能建立本地会话。

单点登录的关键在于 Provider 维护的是全局会话。用户第一次在应用 A 登录后,Provider 会写入自己的 Cookie 或服务端 Session。之后用户访问应用 B,应用 B 发起同样的授权码流程,但 Provider 发现全局会话仍然有效,就不会再次要求用户输入密码,而是直接返回授权码。各应用依然维护各自独立的短期会话,但认证源已经统一,这就是 OIDC 实现 SSO 的底层机制。

二、Spring Boot 依赖与 OIDC 客户端配置

Spring Boot 对 OIDC 的支持已经封装在 Spring Security 中,只需要引入对应的 Starter 即可。以 Maven 项目为例,pom.xml 中至少要包含 spring-boot-starter-security、spring-boot-starter-web 和 spring-boot-starter-oauth2-client。版本号不需要手动指定,Spring Boot 的依赖管理会自动处理。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
</dependencies>

接着在 application.yml 中配置客户端注册信息。Spring Security 会把 registration 和 provider 拆开,registration 描述当前客户端,provider 描述身份提供方的能力。其中 issuer-uri 是最关键的配置,它指向 OIDC Provider 的发行者地址,Spring Security 启动时会请求该地址下的 .well-known/openid-configuration 元数据,自动发现 authorization_endpoint、token_endpoint、userinfo_endpoint 和 jwks_uri 等信息。

spring:
  security:
    oauth2:
      client:
        registration:
          my-oidc-client:
            client-id: spring-boot-app
            client-secret: 8f7c2d8a-9b4e-42af-913e-25d2d8f34c1b
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/my-oidc-client
            scope:
              - openid
              - profile
              - email
        provider:
          my-oidc-client:
            issuer-uri: http://localhost:8080/realms/demo

需要注意 redirect-uri 必须与 Provider 中注册的回调地址完全一致,否则授权流程会被拒绝。scope 中包含 openid 是 OIDC 的强制要求,没有它拿不到 id_token。如果 Provider 使用自签名证书,在开发环境还需配置 SSL 信任,但生产环境建议使用受信任证书。这些配置写完后,Spring Security 会自动暴露一个默认的登录入口 /oauth2/authorization/my-oidc-client,访问它会重定向到 Provider 的授权页。

三、安全配置与用户信息处理

有了客户端配置还不够,还需要显式声明 Spring Security 的过滤链,启用 oauth2Login。在 Spring Security 6 中,推荐声明一个 SecurityFilterChain Bean。通过 oauth2Login 可以配置登录成功后的跳转地址,或者加入自定义的成功处理器。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/error").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .defaultSuccessUrl("/home", true)
            );
        return http.build();
    }
}

登录成功后,默认会跳转到之前被拦截的地址,或者通过 defaultSuccessUrl 指定固定地址。如果需要拿到更多用户属性,可以在控制器方法参数中直接注入 OidcUser。OidcUser 是 Spring Security 对 OIDC 用户的抽象,里面包含 idToken、userInfo 和 authorities。例如下面的接口会返回当前用户的 subject 和邮箱。

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class UserController {
    @GetMapping("/userinfo")
    public Map<String, Object> user(@AuthenticationPrincipal OidcUser oidcUser) {
        return Map.of(
            "subject", oidcUser.getSubject(),
            "email", oidcUser.getEmail(),
            "claims", oidcUser.getClaims()
        );
    }
}

默认情况下,Spring Security 生成的登录入口地址格式为 /oauth2/authorization/{registrationId}。如果系统接入多个 Provider,只要在配置文件中增加不同 registration 即可,登录入口也会自动扩展。对于需要将 OIDC 用户同步到本地数据库的场景,可以自定义 OAuth2UserService,在用户首次登录时根据 subject 或 email 创建本地账号。

四、多应用共享登录与退出处理

如果企业内已经有一个 OIDC Provider,比如 Keycloak,那么多个 Spring Boot 应用接入它就能获得单点登录能力。关键在于所有应用使用相同的 issuer-uri,但 client-id 和 client-secret 各自独立注册。用户第一次访问应用 A 时完成密码认证,Provider 保存全局会话;随后访问应用 B 时,授权码流程会静默完成,用户无感知地进入系统。

要特别注意,每个 Spring Boot 应用仍然有自己的 HttpSession。这是正常的,因为本地 Session 只负责当前应用的状态,真正的认证状态由 Provider 维护。如果误以为必须把 Session 共享到 Redis 才能实现 SSO,反而会让认证源复杂化。OIDC 方案把身份验证彻底外置,业务系统不再保存密码,安全性也更高。

退出登录则需要同时清理两个层级。先销毁当前应用的本地会话,再通知 Provider 销毁全局会话。RP-Initiated Logout 的逻辑是重定向到 Provider 的 end_session_endpoint,并携带 post_logout_redirect_uri。Spring Security 可以自定义 LogoutSuccessHandler 完成这一步。部分 Provider 还支持 Back-Channel Logout,通过注册 logout token 主动通知所有客户端登出,但这需要各客户端暴露回调端点。落地时可以先实现前端登出跳转,后续再考虑无感全局退出。

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import java.io.IOException;
public class OidcLogoutSuccessHandler implements LogoutSuccessHandler {
    private final String logoutUrl;
    public OidcLogoutSuccessHandler(String logoutUrl) {
        this.logoutUrl = logoutUrl;
    }
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
                                Authentication authentication) throws IOException {
        String target = logoutUrl + "?post_logout_redirect_uri="
                + "http://localhost:8080/login";
        response.sendRedirect(target);
    }
}

五、容易踩到的几个配置坑

第一个常见问题是 issuer-uri 末尾多加了斜杠或路径错误。某些 Provider 对 issuer 的格式非常敏感,例如 Keycloak 的 issuer 是 http://localhost:8080/realms/demo,如果写成了 http://localhost:8080/realms/demo/ 会导致元数据请求失败。第二个问题是 scope 中没有 openid,导致 Spring Security 拿不到 OidcUser,只能拿到 OAuth2User,后续使用 getEmail 等方法会报类型不匹配。第三个问题是 redirect-uri 不匹配,Provider 校验回调地址是精确比较,包含端口和路径。

另外,调试 OIDC 流程时建议开启 Spring Security 的 debug 日志,观察 Token 校验和重定向过程。可以在 application.yml 中配置 logging.level.org.springframework.security=DEBUG。遇到 JWT 签名验证失败时,重点检查 Provider 的 JWKS 端点是否可访问,以及本机时间是否偏差过大。如果应用部署在反向代理之后,还需要注意生成的重定向地址是否使用 X-Forwarded 头,否则可能指向内部地址。

广告位 · 文章底部(后台-模块管理替换此区域)