Java `Spring Security` `OAuth 2.0` / `OpenID Connect` `Resource Server` `Authorization Server`

各位观众老爷,大家好!我是今天的主讲人,咱们今天聊聊Java Spring Security OAuth 2.0 / OpenID Connect Resource ServerAuthorization Server 的那些事儿。别害怕,虽然概念听起来挺唬人,但其实也没那么复杂,我会用大白话加上代码,争取让大家听完之后,也能拍着胸脯说一句:“这玩意儿,我会!”

咱们先来明确一下,今天的主题是基于 Spring Security 构建 OAuth 2.0 和 OpenID Connect 的资源服务器(Resource Server)和授权服务器(Authorization Server)。这两个家伙是 OAuth 2.0 和 OpenID Connect 协议中的核心角色,理解它们至关重要。

OAuth 2.0 和 OpenID Connect 的关系

先简单说说 OAuth 2.0 和 OpenID Connect 的关系,OAuth 2.0 是一种授权框架,主要解决的是第三方应用如何安全地访问用户的受保护资源的问题。而 OpenID Connect 是基于 OAuth 2.0 的一个身份验证层,它在 OAuth 2.0 的基础上增加了用户身份信息的传递,让第三方应用不仅可以获得授权,还能知道是谁授的权。可以这么理解:OAuth 2.0 管授权,OpenID Connect 管身份。

角色介绍:Authorization Server vs. Resource Server

  • Authorization Server (授权服务器): 顾名思义,它的主要职责是颁发访问令牌(Access Token)。它负责验证用户的身份,并根据用户的授权决定是否颁发令牌。简单来说,它就是个发通行证的,只有它说了算,谁能拿到通行证。
  • Resource Server (资源服务器): 资源服务器负责保护用户的受保护资源,例如用户的个人资料、照片等。它会验证请求中携带的访问令牌,只有持有有效令牌的请求才能访问资源。它就像个保安,只有持有有效通行证的人才能进入大楼。

搭建授权服务器 (Authorization Server)

咱们先从授权服务器开始。使用 Spring Security 构建授权服务器,我们需要用到 Spring Authorization Server 项目,它基于 Spring Security 5.8+ 构建,提供了 OAuth 2.0 Authorization Server 的完整功能。

  1. 添加依赖:

    首先,在 pom.xml 文件中添加 Spring Authorization Server 的依赖。

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-authorization-server</artifactId>
        <version>1.1.2</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    这里还加入了 spring-boot-starter-jdbch2,是为了方便演示,我们将客户端信息存储在 H2 内存数据库中。实际项目中,建议使用更可靠的数据库。

  2. 配置授权服务器:

    创建一个配置类,用于配置授权服务器。

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.core.Ordered;
    import org.springframework.core.annotation.Order;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
    import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
    import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
    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.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.PasswordEncoder;
    import org.springframework.security.oauth2.core.AuthorizationGrantType;
    import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
    import org.springframework.security.oauth2.core.oidc.OidcScopes;
    import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
    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.configuration.OAuth2AuthorizationServerConfiguration;
    import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
    import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
    import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
    import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
    import org.springframework.security.provisioning.InMemoryUserDetailsManager;
    import org.springframework.security.web.SecurityFilterChain;
    import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
    
    import java.time.Duration;
    import java.util.UUID;
    
    @Configuration
    @EnableWebSecurity
    public class AuthorizationServerConfig {
    
        @Bean
        @Order(Ordered.HIGHEST_PRECEDENCE)
        public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
            OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
            http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                    .oidc(Customizer.withDefaults());   // Enable OIDC endpoint
            http
                    // Redirect to the login page when not authenticated from the authorization endpoint
                    .exceptionHandling((exceptions) -> exceptions
                            .authenticationEntryPoint(
                                    new LoginUrlAuthenticationEntryPoint("/login"))
                    )
                    // Accept access tokens for User Info and/or Client Registration
                    .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()));
    
            return http.build();
        }
    
        @Bean
        public UserDetailsService users() {
            UserDetails user = User.withUsername("user")
                    .password(passwordEncoder().encode("password"))
                    .roles("USER")
                    .build();
            return new InMemoryUserDetailsManager(user);
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
            RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                    .clientId("messaging-client")
                    .clientSecret(passwordEncoder().encode("secret"))
                    .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                    .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                    .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                    .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client")
                    .redirectUri("http://127.0.0.1:8080/authorized")
                    .scope(OidcScopes.OPENID)
                    .scope(OidcScopes.PROFILE)
                    .scope("message.read")
                    .scope("message.write")
                    .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
                    .tokenSettings(TokenSettings.builder()
                            .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                            .accessTokenTimeToLive(Duration.ofMinutes(10))
                            .refreshTokenTimeToLive(Duration.ofHours(8))
                            .reuseRefreshTokens(true)
                            .build())
                    .build();
    
            // Save registered client in db as if in-memory
            JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
            registeredClientRepository.save(registeredClient);
    
            return registeredClientRepository;
        }
    
        @Bean
        public EmbeddedDatabase embeddedDatabase() {
            return new EmbeddedDatabaseBuilder()
                    .generateUniqueName(true)
                    .setType(EmbeddedDatabaseType.H2)
                    .setScriptEncoding("UTF-8")
                    .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
                    .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")
                    .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
                    .build();
        }
    
    }

    代码解释:

    • @EnableWebSecurity: 启用 Spring Security 的 Web 安全功能。
    • @Order(Ordered.HIGHEST_PRECEDENCE): 设置这个 SecurityFilterChain 的优先级最高,确保 OAuth 2.0 授权服务器的配置先生效。
    • authorizationServerSecurityFilterChain: 配置 OAuth 2.0 授权服务器的安全过滤器链。
      • OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http): 应用默认的 OAuth 2.0 授权服务器安全配置。
      • .oidc(Customizer.withDefaults()): 启用 OpenID Connect 支持。
      • .exceptionHandling((exceptions) -> ...): 配置异常处理,当未认证的用户访问授权端点时,重定向到登录页面。
      • .oauth2ResourceServer((resourceServer) -> ...): 配置 OAuth 2.0 资源服务器,允许使用 JWT 访问用户信息和客户端注册端点。
    • UserDetailsService users(): 创建一个内存中的用户,用于演示登录。实际项目中,应该从数据库或其他数据源加载用户。
    • PasswordEncoder passwordEncoder(): 配置密码编码器,用于加密用户密码和客户端密钥。
    • RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate): 配置客户端存储库,这里使用 JdbcRegisteredClientRepository 将客户端信息存储在数据库中。
      • RegisteredClient.withId(...): 创建一个客户端,配置客户端 ID、密钥、授权类型、重定向 URI、Scope 等信息。
      • clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()): 配置客户端设置,这里设置为不需要用户授权确认。
      • tokenSettings(TokenSettings.builder()...build()): 配置令牌设置,包括令牌格式(JWT),有效期,刷新令牌等
    • EmbeddedDatabase embeddedDatabase(): 创建一个嵌入式 H2 数据库,用于存储客户端信息。

    重点说一下 RegisteredClient 的配置:

    属性 说明
    clientId 客户端 ID,用于标识客户端。
    clientSecret 客户端密钥,用于客户端进行身份验证。
    clientAuthenticationMethod 客户端身份验证方法,常用的有 CLIENT_SECRET_BASIC(使用 HTTP Basic 认证)和 CLIENT_SECRET_POST(将客户端 ID 和密钥放在请求体中)。
    authorizationGrantType 授权类型,常用的有 AUTHORIZATION_CODE(授权码模式)、REFRESH_TOKEN(刷新令牌模式)、CLIENT_CREDENTIALS(客户端凭据模式)。
    redirectUri 重定向 URI,授权服务器在完成授权后会将用户重定向到这个 URI。
    scope 权限范围,用于限制客户端可以访问的资源。
    clientSettings 客户端设置,例如是否需要用户授权确认。
    tokenSettings 令牌设置,例如访问令牌的有效期、刷新令牌的有效期、是否可以重用刷新令牌。

    SQL 脚本:

    创建资源服务器之前,我们需要创建数据库表。以下是创建数据库表的 SQL 脚本(oauth2-registered-client-schema.sqloauth2-authorization-schema.sqloauth2-authorization-consent-schema.sql):

    oauth2-registered-client-schema.sql:

    CREATE TABLE IF NOT EXISTS oauth2_registered_client (
        id varchar(100) NOT NULL,
        client_id varchar(100) NOT NULL,
        client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
        client_secret varchar(200) DEFAULT NULL,
        client_secret_expires_at timestamp DEFAULT NULL,
        client_name varchar(200) NOT NULL,
        client_authentication_methods varchar(1000) NOT NULL,
        authorization_grant_types varchar(1000) NOT NULL,
        redirect_uris varchar(1000) DEFAULT NULL,
        scopes varchar(1000) NOT NULL,
        client_settings varchar(2000) NOT NULL,
        token_settings varchar(2000) NOT NULL,
        PRIMARY KEY (id)
    );

    oauth2-authorization-schema.sql:

    CREATE TABLE IF NOT EXISTS oauth2_authorization (
        id varchar(100) NOT NULL,
        registered_client_id varchar(100) NOT NULL,
        principal_name varchar(200) NOT NULL,
        authorization_grant_type varchar(100) NOT NULL,
        authorized_scopes varchar(1000) DEFAULT NULL,
        attributes blob DEFAULT NULL,
        state varchar(500) DEFAULT NULL,
        authorization_code_value blob DEFAULT NULL,
        authorization_code_issued_at timestamp DEFAULT NULL,
        authorization_code_expires_at timestamp DEFAULT NULL,
        authorization_code_metadata blob DEFAULT NULL,
        access_token_value blob DEFAULT NULL,
        access_token_issued_at timestamp DEFAULT NULL,
        access_token_expires_at timestamp DEFAULT NULL,
        access_token_metadata blob DEFAULT NULL,
        access_token_type varchar(100) DEFAULT NULL,
        access_token_scopes varchar(1000) DEFAULT NULL,
        refresh_token_value blob DEFAULT NULL,
        refresh_token_issued_at timestamp DEFAULT NULL,
        refresh_token_expires_at timestamp DEFAULT NULL,
        refresh_token_metadata blob DEFAULT NULL,
        oidc_id_token_value blob DEFAULT NULL,
        oidc_id_token_issued_at timestamp DEFAULT NULL,
        oidc_id_token_expires_at timestamp DEFAULT NULL,
        oidc_id_token_metadata blob DEFAULT NULL,
        PRIMARY KEY (id)
    );

    oauth2-authorization-consent-schema.sql:

    CREATE TABLE IF NOT EXISTS oauth2_authorization_consent (
        registered_client_id varchar(100) NOT NULL,
        principal_name varchar(200) NOT NULL,
        authorities varchar(1000) NOT NULL,
        PRIMARY KEY (registered_client_id, principal_name)
    );

    这些脚本会在应用启动时自动执行,创建必要的数据库表。

  3. 登录页面:

    由于我们启用了 OidcConfigurer,当访问 /oauth2/authorize 端点时,如果没有登录,将会被重定向到登录页面。我们需要创建一个简单的登录页面。

    创建一个 templates/login.html 文件:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Login</title>
    </head>
    <body>
    <h1>Login</h1>
    <form action="/login" method="post">
        <div>
            <label>Username:</label>
            <input type="text" name="username" value="user"/>
        </div>
        <div>
            <label>Password:</label>
            <input type="password" name="password" value="password"/>
        </div>
        <button type="submit">Login</button>
    </form>
    </body>
    </html>

    这个页面很简单,就是一个用户名和密码的输入框,以及一个登录按钮。

搭建资源服务器 (Resource Server)

接下来,咱们搭建资源服务器。资源服务器的主要任务是保护受保护的资源,并验证访问令牌的有效性。

  1. 添加依赖:

    pom.xml 文件中添加 Spring Security 的 OAuth 2.0 资源服务器依赖。

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
  2. 配置资源服务器:

    创建一个配置类,用于配置资源服务器。

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.web.SecurityFilterChain;
    
    import static org.springframework.security.config.Customizer.withDefaults;
    
    @Configuration
    @EnableWebSecurity
    @EnableMethodSecurity
    public class ResourceServerConfig {
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                    .authorizeHttpRequests((authorize) -> authorize
                            .requestMatchers("/public").permitAll()
                            .anyRequest().authenticated()
                    )
                    .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()));
            return http.build();
        }
    }

    代码解释:

    • @EnableWebSecurity: 启用 Spring Security 的 Web 安全功能。
    • @EnableMethodSecurity: 启用方法级别的安全控制。
    • securityFilterChain: 配置资源服务器的安全过滤器链。
      • authorizeHttpRequests((authorize) -> ...): 配置请求授权规则。
        • .requestMatchers("/public").permitAll(): 允许匿名访问 /public 路径。
        • .anyRequest().authenticated(): 其他任何请求都需要进行身份验证。
      • oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults())): 配置 OAuth 2.0 资源服务器,使用 JWT 进行身份验证。
  3. 创建受保护的资源:

    创建一个 Controller,用于提供受保护的资源。

    import org.springframework.security.access.prepost.PreAuthorize;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class ResourceController {
    
        @GetMapping("/resource")
        @PreAuthorize("hasAuthority('SCOPE_message.read')")
        public String resource() {
            return "Hello, Resource Server!";
        }
    
        @GetMapping("/public")
        public String publicResource() {
            return "Hello, Public Resource!";
        }
    }

    代码解释:

    • @RestController: 声明这是一个 REST Controller。
    • @GetMapping("/resource"): 将 resource() 方法映射到 /resource 路径。
    • @PreAuthorize("hasAuthority('SCOPE_message.read')"): 使用方法级别的安全控制,要求用户必须具有 message.read 权限才能访问该资源。
    • @GetMapping("/public"): 将 publicResource() 方法映射到 /public 路径。

测试

  1. 启动授权服务器和资源服务器。

  2. 获取访问令牌。

    • 访问授权服务器的授权端点 /oauth2/authorize,例如:

      http://localhost:9000/oauth2/authorize?response_type=code&client_id=messaging-client&scope=openid%20profile%20message.read&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/messaging-client&state=test

      (这里授权服务器运行在9000端口,资源服务器运行在8080端口,请替换为你实际的端口)

    • 如果未登录,将会重定向到登录页面。输入用户名和密码(我们配置的 user/password)进行登录。

    • 登录成功后,如果客户端需要授权确认,会显示授权确认页面。由于我们配置了 requireAuthorizationConsent(false),所以不会显示授权确认页面,而是直接重定向到 redirect_uri

    • 授权服务器会将授权码(Authorization Code)作为参数添加到 redirect_uri 中。

    • 使用授权码向授权服务器的令牌端点 /oauth2/token 发送请求,获取访问令牌。

      curl -X POST 
        -H "Content-Type: application/x-www-form-urlencoded" 
        -H "Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ=" 
        -d "grant_type=authorization_code&code={授权码}&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/messaging-client" 
        http://localhost:9000/oauth2/token

      (将 {授权码} 替换为实际的授权码)

      • Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ=:使用 HTTP Basic 认证,其中 messaging-client:secret 是客户端 ID 和密钥,需要进行 Base64 编码。
      • grant_type=authorization_code:指定授权类型为授权码模式。
      • code={授权码}:授权码。
      • redirect_uri=http://127.0.0.1:8080/login/oauth2/code/messaging-client:重定向 URI。
    • 授权服务器会返回一个 JSON 响应,包含访问令牌(access_token)、刷新令牌(refresh_token)等信息。

  3. 访问受保护的资源。

    • 使用访问令牌向资源服务器的 /resource 端点发送请求。

      curl -H "Authorization: Bearer {访问令牌}" http://localhost:8080/resource

      (将 {访问令牌} 替换为实际的访问令牌)

      • Authorization: Bearer {访问令牌}:使用 Bearer 认证,将访问令牌放在请求头中。
    • 如果访问令牌有效,资源服务器会返回 "Hello, Resource Server!"。

  4. 访问公共资源。

    • 直接访问资源服务器的 /public 端点。

      curl http://localhost:8080/public
    • 资源服务器会返回 "Hello, Public Resource!"。

总结

咱们今天讲了如何使用 Spring Security 构建 OAuth 2.0 和 OpenID Connect 的授权服务器和资源服务器。虽然涉及到的概念和配置比较多,但只要理解了授权服务器和资源服务器的角色,以及 OAuth 2.0 和 OpenID Connect 的流程,就能轻松应对。

记住,授权服务器是发通行证的,资源服务器是看门的。它们一起保护着咱们的宝贵资源。

希望今天的讲座对大家有所帮助。如果有什么问题,欢迎随时提问。咱们下次再见!

扩展思考:

主题 说明
自定义授权流程 Spring Authorization Server 提供了丰富的扩展点,可以自定义授权流程,例如自定义用户认证、自定义授权确认页面、自定义令牌生成等。
使用 JWT 存储更多信息 JWT (JSON Web Token) 是一种紧凑、自包含的方式,用于安全地传输信息。可以在 JWT 中存储更多用户信息,例如用户 ID、角色等,方便资源服务器进行权限控制。
分布式 Session 管理 在分布式系统中,Session 管理是一个挑战。可以使用 Spring Session 将 Session 存储在 Redis 或其他集中式存储中,实现 Session 共享。
OAuth 2.0 客户端 除了授权服务器和资源服务器,OAuth 2.0 还有一个重要的角色:客户端。客户端是代表用户向授权服务器请求授权,并使用访问令牌访问资源的应用程序。可以使用 Spring Security 的 OAuth 2.0 Client 模块构建 OAuth 2.0 客户端。
OpenID Connect 的更多特性 OpenID Connect 在 OAuth 2.0 的基础上增加了用户身份信息的传递。可以利用 OpenID Connect 的更多特性,例如用户身份声明、会话管理等,构建更安全的身份验证系统。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注