Ohhnews

分类导航

$ cd ..
foojay原文

如何使用OpenID Connect和pac4j保护Spring Boot应用

#spring boot#openid connect#pac4j#身份认证#java

pac4j 是一个开源的 Java 安全库,用于身份认证和授权。它支持 OpenID Connect、OAuth 2.0、SAML 和 CAS 等协议,并可与多种 Java Web 框架集成。我是 Jérôme LELEU,pac4j 的维护者。在本教程中,我们将把它提供的 OpenID Connect 客户端与 Spring Boot 结合使用。

你希望用户使用 Keycloak、Google、Microsoft Entra ID、Okta、Auth0 甚至 CAS 服务器登录你的 Java 应用。用户在这些平台上已经拥有账号,因此你的应用不应再向他们索取另一个密码。

这正是 OpenID Connect(OIDC)的用途。它在 OAuth 2.0 之上增加了一个身份层:由提供方对用户进行身份认证,你的应用会收到一个 ID token,即一个描述登录者身份的 JWT,通常由提供方签名。用户的密码始终保留在提供方那里。

下面我们使用 pac4j 与 Spring Boot 来实践这一流程。我们将先从公共的 pac4j 测试服务器开始,然后看看针对 Keycloak、Google 或 Azure AD 需要做哪些改动。同样的方法也适用于其他 OpenID Connect 提供方。

你需要准备:

  • Java 17+ 和 Maven
  • 一个可以注册应用的 OpenID Connect 提供方,或者使用下面用到的公共演示服务器。

1)创建 Maven 项目

创建一个使用 Java 17 的空 Spring Boot 项目:

mkdir -p spring-oidc-app/src/main/java/org/example
mkdir -p spring-oidc-app/src/main/resources
cd spring-oidc-app

在项目根目录创建 pom.xml 文件。Spring Boot parent 负责管理 Spring 依赖,其 Maven 插件负责运行应用:

<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>4.1.1</version>
        <relativePath/>
    </parent>
    <groupId>org.example</groupId>
    <artifactId>spring-oidc-app</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Add the dependencies from section 2 here. -->
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

将下面的 Java 类保存在 org.example 包中,这样 Spring Boot 才能发现配置类和控制器。将应用设置放在 src/main/resources/application.yml 中。

2)添加 Maven 依赖

在 <dependencies> 元素中,除了 Spring MVC 之外,还需要两个 pac4j 构件:Spring MVC 集成模块和 OpenID Connect 模块。

<!-- Spring Boot web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- pac4j implementation for Spring MVC so for Spring Boot as well -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>spring-webmvc-pac4j</artifactId>
    <version>8.0.4</version>
</dependency>
<!-- pac4j support for OpenID Connect -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-oidc</artifactId>
    <version>6.5.9</version>
</dependency>

为什么需要两个依赖?pac4j-oidc 负责处理协议,它构建在 Nimbus OAuth 2.0 / OpenID Connect SDK 之上;而 spring-webmvc-pac4j 负责将其连接到 Spring MVC。OidcClient 本身对 Spring 一无所知:你同样可以将它与 Jakarta EE、Play、Vert.x、JAX-RS 以及其他 pac4j 集成 一起使用。

3)配置 OpenID Connect(OIDC)认证

创建 src/main/java/org/example/SecurityConfig.java:

仅用于演示:未签名的 ID token

公共的 pac4j 演示服务器签发的是 未签名的 ID token。因此,下面的示例使用 setAllowUnsignedIdTokens(true) 仅仅是为了与该服务器配合使用。请勿在生产环境的提供方上启用此选项。 切换到真实提供方时请移除该调用,以便 pac4j 使用提供方的 JWKS 验证 ID token 的签名。演示用的 client ID 和 secret 是公开的测试凭据。

package org.example;

import org.pac4j.core.config.Config;
import org.pac4j.springframework.config.Pac4jSecurityConfig;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

@Configuration
public class SecurityConfig extends Pac4jSecurityConfig {

    @Value("${app.base-url:http://localhost:8080}")
    private String baseUri;

    @Bean
    public Config config() {
        // configuration of the authentication via the OpenID Connect protocol
        final var config = new OidcConfiguration()
            .setDiscoveryURI("https://www.casserverpac4j.dev/oidc/.well-known/openid-configuration")
            .setClientId("myclient")
            .setSecret("mysecret")
            .setAllowUnsignedIdTokens(true);
        return new Config(baseUri + "/callback", new OidcClient(config));
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        // the /protected/** URLs require the OIDC authentication
        addSecurity(registry, "OidcClient").addPathPatterns("/protected/**");
    }
}

这里需要配置两件事:OIDC 客户端,以及我们应用的 URL。

对于客户端,setDiscoveryURI 指向提供方的 .well-known/openid-configuration 文档。pac4j 会从中读取授权、令牌、用户信息和 JWKS 端点。因此,你无需自行配置每个端点。setClientId 和 setSecret 是你在提供方注册应用后获得的凭据。

在应用一侧,Pac4jSecurityConfig 会注册 /callback 和 /logout。回调 URL 来自 new Config(baseUri + "/callback", ...),pac4j 会在其后追加 ?client_name=OidcClient。这个完整的 URL 就是需要在提供方注册的重定向 URI。

最后,addSecurity(registry, "OidcClient") 保护 /protected/**。匿名用户请求这些页面时会被重定向到提供方进行登录。

默认情况下,pac4j 使用 授权码流程,并在提供方于其发现文档中声明支持时添加 PKCE。

从演示服务器切换到自己的提供方时,请按照上面的警告说明移除 setAllowUnsignedIdTokens(true)。

4)在身份提供方注册应用

每个提供方都需要相同的三样东西:一个应用(或“客户端”),其 重定向 URI 为 http://localhost:8080/callback?client_name=OidcClient,以及由此得到的 client ID 和 secret。不同之处在于发现文档所在的位置,而 pac4j 为常见提供方提供了专用的客户端。

对于 Keycloak:在你的 realm 中创建一个机密客户端,并设置其“Valid redirect URIs”。KeycloakOidcConfiguration 会根据服务器基础 URL 和 realm 名称构建发现 URL:

final var config = new KeycloakOidcConfiguration()
    .setBaseUri("https://keycloak.example.com")
    .setRealm("myrealm");
config.setClientId("myclient");
config.setSecret("mysecret");
return new Config(baseUri + "/callback", new KeycloakOidcClient(config));

对于 Google:在 Google Cloud 控制台中创建一个类型为“Web application”的“OAuth client ID”,并将重定向 URI 添加到“Authorized redirect URIs”。GoogleOidcClient 已经知道 Google 的发现 URL:

final var config = new OidcConfiguration()
    .setClientId("xxx.apps.googleusercontent.com")
    .setSecret("GOCSPX-...");
return new Config(baseUri + "/callback", new GoogleOidcClient(config));

对于 Microsoft Entra ID(Azure AD):在 Entra 门户中注册一个应用,在“Web”平台下添加重定向 URI,并创建一个客户端密钥。AzureAd2Client 接收租户标识符:

final var config = new AzureAd2OidcConfiguration("38c46e5a-21f0-46e5-940d-3ca06fd1a330");
config.setClientId("788339d7-1c44-4732-97c9-134cb201f01f");
config.setSecret("...");
return new Config(baseUri + "/callback", new AzureAd2Client(config));

对于 Okta、Auth0、CAS 服务器或任何其他提供方,只要使用带有该提供方发现 URL 的通用 OidcClient 即可。将重定向 URI 中的 client_name=OidcClient 替换为你所用客户端的名称(KeycloakOidcClient、GoogleOidcClient、AzureAd2Client)。

同时也要更新安全拦截器: 传给 addSecurity 的名称必须与配置的客户端一致。例如,使用 KeycloakOidcClient 时,请将第 3 步中的拦截器配置替换为:

addSecurity(registry, "KeycloakOidcClient").addPathPatterns("/protected/**");

对于相应的提供方,请在这两处都使用 GoogleOidcClient 或 AzureAd2Client。

5)访问已认证的用户

创建 src/main/java/org/example/Application.java,对外暴露一个公共页面和一个受保护页面:

package org.example;

import org.pac4j.core.profile.ProfileManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class Application {

    @Autowired
    private ProfileManager profileManager;

    @GetMapping(value = "/", produces = "text/html")
    public String index() {
        return "<h1>Public area</h1><p><a href='/protected/index'>Protected area</a></p>"
            + "<p><a href='/logout'>Logout</a></p>";
    }

    @GetMapping(value = "/protected/index", produces = "text/plain")
    public String secure() {
        return "Protected area\n" + profileManager.getProfiles() + "\nVisit /logout to sign out.";
    }
}

现在用户已经通过身份认证,我们该如何获取他们的信息?ProfileManager 会读取保存在会话中的 profile。对于 OIDC,这是一个 OidcProfile,它提供了标准声明的 getter 方法,并可以访问原始令牌:

final var profile = (OidcProfile) profileManager.getProfile().orElseThrow();
profile.getId();            // the "sub" claim
profile.getEmail();
profile.getDisplayName();
profile.getAttribute("preferred_username");
profile.getIdToken();       // the parsed ID token (JWT)
profile.getAccessToken();   // to call the provider's APIs

具体存在哪些声明取决于你请求的 scope。默认是 openid profile email;可以使用 config.setScope("openid profile email phone") 添加更多。

6)登出

/logout 链接会从应用会话中移除 profile。这是 本地登出:用户可能仍在提供方处于登录状态。

若要同时结束提供方那边的会话,请在 application.yml 中启用集中式登出:

pac4j:
  logout:
    centralLogout: true
    defaultUrl: http://localhost:8080/

对于支持 OIDC 登出的提供方,pac4j 会将浏览器重定向到发现文档中的 end_session_endpoint,并将绝对默认 URL 作为 post_logout_redirect_uri 提供。请在提供方处将 http://localhost:8080/ 注册为允许的登出后重定向 URI。像 / 这样的相对默认 URL 适用于本地登出,但不会作为返回 URL 传给提供方。

如果你的提供方支持 OIDC 登出但没有公布其端点,可以使用 config.setLogoutUrl(...) 显式设置它。登出支持情况和注册要求因提供方而异;专用的 GoogleOidcClient 使用它自己的登出动作,而不是这种基于发现文档的流程。

7)运行应用

创建 src/main/java/org/example/SpringBootDemo.java:

package org.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringBootDemo {
    public static void main(final String[] args) {
        SpringApplication.run(SpringBootDemo.class, args);
    }
}

使用 mvn spring-boot:run 启动应用。

打开 http://localhost:8080/ 并点击 Protected area。你将被重定向到身份提供方进行登录,随后返回受保护页面,控制器会打印你的 profile。

如果出现问题:

  • 提供方报 “Invalid redirect URI” 或 “redirect_uri_mismatch”:注册的 URI 必须是完整的回调 URL,包括 ?client_name=OidcClient(或第 4 步中配置的专用客户端名称)。

  • 令牌请求报 “invalid_client”:客户端密钥错误,或者该客户端在提供方处是公共客户端,而 pac4j 发送了密钥。

  • ID token 签名验证失败:检查发现文档是否指向预期的提供方,其 JWKS 是否可访问并包含当前的签名密钥,以及预期的签名算法是否与提供方客户端配置匹配。如果需要显式指定算法,请使用 config.setIdTokenSigningAlgorithm(...) 传入提供方所配置的算法;将其改为 ES256 并不是解决签名错误的通用方法。

  • 姓名为空或邮箱为空:scope 或提供方的声明映射没有释放这些信息。请先检查 scope,再检查提供方处的客户端配置。## 8) 了解更多

  • Spring Boot OIDC 演示:完整的参考应用。

  • Java 的 OIDC 客户端文档:所有 OidcConfiguration 选项、隐式流、private_key_jwt 客户端认证、nonce 与 state 处理。

  • 直接 OIDC 认证:使用提供商签发的访问令牌保护 REST API。

  • 当你的应用属于某个信任联盟时,可使用 OpenID Federation。

探索更多 pac4j 框架和更多认证机制……

改编自 如何使用 OIDC 保护 Java 客户端应用(基于 Spring Boot),原文最初发布于 pac4j.org。

[LOADING...]
作者

Jérôme LELEU

Jérôme LELEU 是 pac4j 的创建者和维护者。pac4j 是一个开源 Java 安全库,用于跨多种 Web 框架进行身份认证和授权。

加入讨论