Spring Security 持久化OAuth2客戶(hù)端

Spring Security 持久化OAuth2客戶(hù)端

之前文章中介紹過(guò)了客戶(hù)端通過(guò)向授權(quán)服務(wù)器(使用Spring Authorization Server)請(qǐng)求授權(quán)并訪(fǎng)問(wèn)資源服務(wù)器受保護(hù)資源。在創(chuàng)建OAuth2客戶(hù)端服務(wù)時(shí),客戶(hù)端注冊(cè)通常從application.yml文件中自動(dòng)加載,Spring 自動(dòng)配置使用OAuth2ClientPropertiesspring.security.oauth2.client.registration.[registrationId]創(chuàng)建一個(gè)ClientRegistration并實(shí)例化ClientRegistrationRepository

以下Spring自動(dòng)配置OAuth2ClientRegistrationRepositoryConfiguration代碼如下:

@Configuration(
    proxyBeanMethods = false
)
@EnableConfigurationProperties({OAuth2ClientProperties.class})
@Conditional({ClientsConfiguredCondition.class})
class OAuth2ClientRegistrationRepositoryConfiguration {
    OAuth2ClientRegistrationRepositoryConfiguration() {
    }

    @Bean
    @ConditionalOnMissingBean({ClientRegistrationRepository.class})
    InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) {
        List<ClientRegistration> registrations = new ArrayList(OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(properties).values());
        return new InMemoryClientRegistrationRepository(registrations);
    }
}

如您所見(jiàn),ClientRegistrationRepository默認(rèn)實(shí)現(xiàn)并僅有一個(gè)實(shí)現(xiàn)類(lèi)是InMemoryClientRegistrationRepository,它將ClientRegistration存儲(chǔ)在內(nèi)存中,而在生產(chǎn)環(huán)境中此方式可能會(huì)有一定局限性。

在本文中您將了解如何通過(guò)擴(kuò)展ClientRegistrationRepository實(shí)現(xiàn)OAuth2客戶(hù)端持久化。

OAuth2客戶(hù)端服務(wù)實(shí)現(xiàn)

在本節(jié)中,您將創(chuàng)建一個(gè)簡(jiǎn)單的OAuth2客戶(hù)端服務(wù),并通過(guò)數(shù)據(jù)庫(kù)存儲(chǔ)OAuth2客戶(hù)端信息,現(xiàn)在看代碼!

maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jdbc</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-webflux</artifactId>
  <version>5.3.9</version>
</dependency>
<dependency>
  <groupId>io.projectreactor.netty</groupId>
  <artifactId>reactor-netty</artifactId>
  <version>1.0.9</version>
</dependency>

<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.21</version>
</dependency>

...

配置

首先讓我們通過(guò)application.yml配置服務(wù)端口信息和數(shù)據(jù)庫(kù)連接信息:

server:
  port: 8070
  
spring:
  datasource:
    druid:
      db-type: mysql
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/persistence_oauth2_client?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
      username: <<username>> # 修改用戶(hù)名
      password: <<password>> # 修改密碼

接下來(lái)我們根據(jù)ClientRegistration來(lái)創(chuàng)建數(shù)據(jù)庫(kù)表用于存儲(chǔ)OAuth2客戶(hù)端信息:

CREATE TABLE `oauth2_registered_client`
(
    `registration_id`                 varchar(100)  NOT NULL,
    `client_id`                       varchar(100)  NOT NULL,
    `client_secret`                   varchar(200)  DEFAULT NULL,
    `client_authentication_method`    varchar(100)  NOT NULL,
    `authorization_grant_type`        varchar(100)  NOT NULL,
    `client_name`                     varchar(200)  DEFAULT NULL,
    `redirect_uri`                    varchar(1000) NOT NULL,
    `scopes`                          varchar(1000) NOT NULL,
    `authorization_uri`               varchar(1000) DEFAULT NULL,
    `token_uri`                       varchar(1000) NOT NULL,
    `jwk_set_uri`                     varchar(1000) DEFAULT NULL,
    `issuer_uri`                      varchar(1000) DEFAULT NULL,
    `user_info_uri`                   varchar(1000) DEFAULT NULL,
    `user_info_authentication_method` varchar(100)  DEFAULT NULL,
    `user_name_attribute_name`        varchar(100)  DEFAULT NULL,
    `configuration_metadata`          varchar(2000) DEFAULT NULL,
    PRIMARY KEY (`registration_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

下面將是我們通過(guò)實(shí)現(xiàn)ClientRegistrationRepository擴(kuò)展的JdbcClientRegistrationRepository

public class JdbcClientRegistrationRepository implements ClientRegistrationRepository {
    private static final String COLUMN_NAMES = "registration_id,client_id,client_secret,client_authentication_method,authorization_grant_type,client_name,redirect_uri,scopes,authorization_uri,token_uri,jwk_set_uri,issuer_uri,user_info_uri,user_info_authentication_method,user_name_attribute_name,configuration_metadata";
    private static final String TABLE_NAME = "oauth2_registered_client";
    private static final String LOAD_CLIENT_REGISTERED_SQL = "SELECT " + COLUMN_NAMES + " FROM " + TABLE_NAME + " WHERE ";
    private static final String INSERT_CLIENT_REGISTERED_SQL = "INSERT INTO " + TABLE_NAME + "(" + COLUMN_NAMES + ") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
    private static final String UPDATE_CLIENT_REGISTERED_SQL = "UPDATE " + TABLE_NAME + " SET client_id = ?,client_secret = ?,client_authentication_method = ?,authorization_grant_type = ?,client_name = ?,redirect_uri = ?,scopes = ?,authorization_uri = ?,token_uri = ?,jwk_set_uri = ?,issuer_uri = ?,user_info_uri = ?,user_info_authentication_method = ?,user_name_attribute_name = ? WHERE registration_id = ?";
    private final JdbcOperations jdbcOperations;
    private RowMapper<ClientRegistration> clientRegistrationRowMapper;
    private Function<ClientRegistration, List<SqlParameterValue>> clientRegistrationListParametersMapper;


    public JdbcClientRegistrationRepository(JdbcOperations jdbcOperations) {
        Assert.notNull(jdbcOperations, "JdbcOperations can not be null");
        this.jdbcOperations = jdbcOperations;
        this.clientRegistrationRowMapper = new ClientRegistrationRowMapper();
        this.clientRegistrationListParametersMapper = new ClientRegistrationParametersMapper();
    }

    @Override
    public ClientRegistration findByRegistrationId(String registrationId) {
        Assert.hasText(registrationId, "registrationId cannot be empty");
        return this.findBy("registration_id = ?", registrationId);
    }

    private ClientRegistration findBy(String filter, Object... args) {
        List<ClientRegistration> result = this.jdbcOperations.query(LOAD_CLIENT_REGISTERED_SQL + filter, this.clientRegistrationRowMapper, args);
        return !result.isEmpty() ? result.get(0) : null;
    }


    public void save(ClientRegistration clientRegistration) {
        Assert.notNull(clientRegistration, "clientRegistration cannot be null");
        ClientRegistration existingClientRegistration = this.findByRegistrationId(clientRegistration.getRegistrationId());
        if (existingClientRegistration != null) {
            this.updateRegisteredClient(clientRegistration);
        } else {
            this.insertClientRegistration(clientRegistration);
        }
    }

    private void updateRegisteredClient(ClientRegistration clientRegistration) {
        List<SqlParameterValue> parameterValues = this.clientRegistrationListParametersMapper.apply(clientRegistration);
        PreparedStatementSetter statementSetter = new ArgumentPreparedStatementSetter(parameterValues.toArray());
        this.jdbcOperations.update(UPDATE_CLIENT_REGISTERED_SQL, statementSetter);
    }

    private void insertClientRegistration(ClientRegistration clientRegistration) {
        List<SqlParameterValue> parameterValues = this.clientRegistrationListParametersMapper.apply(clientRegistration);
        PreparedStatementSetter statementSetter = new ArgumentPreparedStatementSetter(parameterValues.toArray());
        this.jdbcOperations.update(INSERT_CLIENT_REGISTERED_SQL, statementSetter);
    }
  
  //...省略部分代碼
}

之后我們將創(chuàng)建SecurityConfig安全配置類(lèi),在此類(lèi)中創(chuàng)建OAuth2 Client所需特定的Bean。首先我們將實(shí)例化上述自定義的JdbcClientRegistrationRepository

@Bean
public ClientRegistrationRepository clientRegistrationRepository(JdbcTemplate jdbcTemplate) {
  return new JdbcClientRegistrationRepository(jdbcTemplate);
}

ClientRegistration:表示使用 OAuth 2.0 或 OpenID Connect (OIDC) 注冊(cè)的客戶(hù)端。它包含有關(guān)客戶(hù)端的所有基本信息,例如客戶(hù)端 ID、客戶(hù)端機(jī)密、授權(quán)類(lèi)型和各種 URI。

ClientRegistrationRepository:這是一個(gè)包含ClientRegistrations并負(fù)責(zé)持久化。


接下來(lái)配置OAuth2AuthorizedClient管理類(lèi)OAuth2AuthorizedClientService

@Bean
public OAuth2AuthorizedClientService authorizedClientService(
  JdbcTemplate jdbcTemplate,
  ClientRegistrationRepository clientRegistrationRepository) {
  return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
}

OAuth2AuthorizedClient:表示授權(quán)客戶(hù)端。這是一個(gè)包含客戶(hù)端注冊(cè)但添加身份驗(yàn)證信息的組合類(lèi)。

OAuth2AuthorizedClientService:負(fù)責(zé)OAuth2AuthorizedClient在 Web 請(qǐng)求之間進(jìn)行持久化。


定義JdbcOAuth2AuthorizedClientService需要?jiǎng)?chuàng)建所需數(shù)據(jù)表,你可以在OAuth2 Client Schema中獲取表定義:

CREATE TABLE oauth2_authorized_client
(
    client_registration_id  varchar(100)                            NOT NULL,
    principal_name          varchar(200)                            NOT NULL,
    access_token_type       varchar(100)                            NOT NULL,
    access_token_value      blob                                    NOT NULL,
    access_token_issued_at  timestamp                               NOT NULL,
    access_token_expires_at timestamp                               NOT NULL,
    access_token_scopes     varchar(1000) DEFAULT NULL,
    refresh_token_value     blob          DEFAULT NULL,
    refresh_token_issued_at timestamp     DEFAULT NULL,
    created_at              timestamp     DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (client_registration_id, principal_name)
);

接下來(lái)配置OAuth2AuthorizedClientRepository容器類(lèi):

@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository(
  OAuth2AuthorizedClientService authorizedClientService) {
  return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}

OAuth2AuthorizedClientRepository:是一個(gè)容器類(lèi),用于在請(qǐng)求之間保存和持久化授權(quán)客戶(hù)端。這里通過(guò)JdbcOAuth2AuthorizedClientService將客戶(hù)端存儲(chǔ)在數(shù)據(jù)庫(kù)中。


接下來(lái)實(shí)例化包含授權(quán)流程的邏輯的管理器類(lèi):

@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                      OAuth2AuthorizedClientRepository authorizedClientRepository) {

  OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
    .builder()
    .authorizationCode()
    .refreshToken()
    .build();
  DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
  authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

  return authorizedClientManager;
}

OAuth2AuthorizedClientManager:是包含處理授權(quán)流程的邏輯的管理器類(lèi)。最重要的是,它使用OAuth2AuthorizedClientProvider處理不同授權(quán)類(lèi)型和 OAuth 2.0 提供者的實(shí)際請(qǐng)求邏輯。它還委托OAuth2AuthorizedClientRepository在客戶(hù)端授權(quán)成功或失敗時(shí)調(diào)用成功或失敗處理程序。


現(xiàn)在讓我們創(chuàng)建一個(gè)WebClient實(shí)例用于向資源服務(wù)器執(zhí)行HTTP請(qǐng)求:

 @Bean
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .apply(oauth2Client.oauth2Configuration())
                .build();
    }


最后,我們將配置Spring Security安全配置:

 @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .formLogin(login -> {
                    login.loginPage("/login").permitAll();
                })
                .oauth2Client(withDefaults());
        return http.build();
    }

這里配置所有請(qǐng)求需要認(rèn)證授權(quán),提供Form表單認(rèn)證方式,并通過(guò)thymeleaf自定義登錄模版,此處代碼并不再本文講解范圍內(nèi),以下將不展示具體細(xì)節(jié)。

訪(fǎng)問(wèn)資源列表

我們將創(chuàng)建一個(gè)PersistenceClientController,并使用WebClient向資源服務(wù)器發(fā)起HTTP請(qǐng)求:

@RestController
public class PersistenceClientController {
    @Autowired
    private WebClient webClient;

    @GetMapping(value = "/client/test")
    public List<String> getArticles(@RegisteredOAuth2AuthorizedClient("messaging-client-authorization-code") OAuth2AuthorizedClient authorizedClient) {
        return this.webClient
                .get()
                .uri("http://127.0.0.1:8090/resource/article")
                .attributes(oauth2AuthorizedClient(authorizedClient))
                .retrieve()
                .bodyToMono(List.class)
                .block();
    }
}


在本文中,您看到了OAuth2客戶(hù)端服務(wù)持久化到數(shù)據(jù)庫(kù)的實(shí)現(xiàn)方法,對(duì)于其他授權(quán)服務(wù)器和資源服務(wù)器配置將不再講解,如果您感興趣可以參考 此文章將JWT與Spring Security OAuth2結(jié)合使用。

結(jié)論

如果您對(duì)這篇文章有任何疑問(wèn),請(qǐng)?jiān)谙旅嫣砑釉u(píng)論。與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容