피곤핑
코딩일탈
피곤핑
전체 방문자
오늘
어제
  • 분류 전체보기
    • Kotlin & Java
    • Spring
      • Spring Security
      • Spring
    • 네트워크
    • JavaScript & Node js
    • Docker
    • Python3
    • Unity
    • 딥러닝
    • 객체지향프로그래밍
    • Error 보고서
    • 나의 이야기 & 회고
    • HTML & CSS
    • Archive
    • 독서

블로그 메뉴

  • 홈
  • 방명록

공지사항

인기 글

태그

  • 99클럽
  • 오블완
  • 코딩테스트준비
  • 항해99
  • 티스토리챌린지
  • JavaScript
  • 개발자취업
  • TiL
  • Client
  • nodejs

최근 댓글

hELLO · Designed By 정상우.
피곤핑

코딩일탈

[spring-authorzaiton-server] RegisteredClient를 JPA 로 구현하기
Spring/Spring Security

[spring-authorzaiton-server] RegisteredClient를 JPA 로 구현하기

2024. 6. 8. 12:02

 

https://docs.spring.io/spring-authorization-server/reference/getting-started.html

 

1. Spring Authorization Server 의 Core Model 을 알아보자

 

1) RegisteredClient

  • 인가 서버에 등록된 클라이언트를 의미한다.
  • OAuth2.0 또는 OAuth2.1 을 통해 인증 및 권한 부여를 요청하는 클라이언트를 구성하는데 사용된다.
  • 예를 들어, 클라이언트가 authorization_code 또는 client_credentials 와 같은 권한 부여 흐름을 시작하려면 먼저 클라이언트를 권한 부여 서버에 등록해야한다.
  • 클라이언트 등록시 클라이언트는 고유한 client_id, client_secret 및 고유한 클라이언트 식별자와 연결된 메타 데이터를 할당한다.
  • 클라이언트의 주요 목적은 보호된 리소스에 대한 액세스를 요청하는 것으로 클라이언트는 먼저 권한 부여 서버를 인증하고 액세스 토큰과 교환을 요청한다.

▶ 주요 필드 및 설정

public class RegisteredClient implements Serializable {
	private String id;
	private String clientId;
	private Instant clientIdIssuedAt;
	private String clientSecret;
	private Instant clientSecretExpiresAt;
	private String clientName;
	private Set<ClientAuthenticationMethod> clientAuthenticationMethods;
	private Set<AuthorizationGrantType> authorizationGrantTypes;
	private Set<String> redirectUris;
	private Set<String> postLogoutRedirectUris;
	private Set<String> scopes;
	private ClientSettings clientSettings;
	private TokenSettings tokenSettings;

	...

}

 

1. 클라이언트ID (ClientId): 클라이언트를 식별하는 고유한 문자열

2. 클라이언트시크릿 (ClientSecret): 클라이언트를 인증하기 위해 사용되는 비밀번호 또는 키. 비밀이 필요한 클라이언트 유형에서 사용됨

3. 클라이언트 인증 방법 (Client Authentication Methods): 클라이언트가 인증 서버에 자신을 인증하는 방법

   ex. client_secret_basic, client_secret_post, none, ....

4. 권한 부여 타입 (Authorization Grant Types): 클라이언트가 요청할 수 있는 권한 부여 방식

   ex. authoriation_code, client_credentials, refresh_token, ...

5. 리다이렉트 URL (Redirect URIs):  인증 코드 또는 액세스 토큰을 전달받을 클라이언트의 URL 목록

6. 스코프 (Scopes): 클라이언트가 요청할 수 있는 권한의 범위

7. 클라이언트 설정 (Client Settings): PKCE 사용 여부, 리다이렉트 URL 요구 여부 등의 추가 설정

8. 토큰 설정 (Token Settings): 액세스 토큰과 리프레시 토큰의 유효 기간 등의 설정

 

▶  JAVA 코드로 예제 구성해보기

- AuthorizationServerConfig 에서 간단하게 인메모리 Repository 빈으로 등록하여 설정해볼 수 있다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;

@Configuration
public class AuthorizationServerConfig {

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("client-id")
                .clientSecret("{noop}client-secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("https://example.com/callback")
                .scope("read")
                .scope("write")
                .tokenSettings(TokenSettings.builder()
                        .accessTokenTimeToLive(Duration.ofHours(1))
                        .refreshTokenTimeToLive(Duration.ofDays(30))
                        .build())
                .clientSettings(ClientSettings.builder()
                        .requireProofKey(true)
                        .requireAuthorizationConsent(true)
                        .build())
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }
}

2) OAuth2Authorization

Spring Authorization Server 에서 OAuth2.0 및 OpenID Connect 1.0 토큰과 관련된 모든 정보와 상태를 캡슐화 하는 핵심 엔티티이다. 이 객체는 특정 클라이언트 애플리케이션과 사용자 간의 인증 및 권한 부여 과정에서 발생하는 다양한 데이터를 저장하는데 사용한다.

▶  주요 필드 및 설정

public class OAuth2Authorization implements Serializable {
	private String id;
	private String registeredClientId;
	private String principalName;
	private AuthorizationGrantType authorizationGrantType;
	private Set<String> authorizedScopes;
	private Map<Class<? extends OAuth2Token>, Token<?>> tokens;
	private Map<String, Object> attributes;

	...

}

 

1. ID: OAuth2Authorization 객체를 식별하는 고유한 ID

2. 등록된 클라이언트 (Registered Client): 클라이언트 애플리케이션을 나타내는 RegisteredClient 객체

3. Principal 이름: 인증된 사용자를 나타내는 이름

4. Authorization Grant Type: 권한 부여 타입

5. Attributes: 추가적인 속성 정보를 저장하는 맵

6. Tokens: 발급된 토큰 (액세스 토큰, 리프레시 토큰, ID 토큰 등)을 저장하는 정보

 

해당 포스팅에서는 OAuth2Authorization 객체에 대해서는 따로 다루지 않으니 자세한 부분은 공식문서를 참고해 주세요!

https://docs.spring.io/spring-authorization-server/reference/core-model-components.html#oauth2-authorization

 

2. 인메모리가 아닌 로컬 DB 로 연동해서 사용하고 싶은데 어떻게 해야할까?

 

아래 step 을 따라가기 전에 로컬 프로젝트에서 databse 관련 dependency 가 먼저 설정되어있어야한다.

또한, 이 포스팅에서는 Spring Data JPA 를 사용하기 때문에 해당 의존성도 설치해주어야 한다.

스프링 부트는 3.2.2 버전을 사용했다. (설정 참고)

스프링 부트 3.2.2 에서 spring-security-oauth2-authorization-server 버전은 1.2.1 버전이다.

 

implementation 'org.springframework.security:spring-security-oauth2-authorization-server'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

1) 엔티티 클래스를 만든다.

- ClientEntity.java 클래스를 만든다.

   - @GeneratedValue(strategy= GenerationType.IDENTITY) 같은 건 각자 프로젝트에 맞게 쓰면된다.

package com.server.authorization.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(name = "client")
public class ClientEntity {
  @Id
  @GeneratedValue(strategy= GenerationType.IDENTITY)
  private Long id;

  private String clientId;

  private Instant clientIdIssuedAt;

  private String clientSecret;

  private Instant clientSecretExpiresAt;

  private String clientName;

  @Column(length = 1000)
  private String clientAuthenticationMethods;

  @Column(length = 1000)
  private String authorizationGrantTypes;

  @Column(length = 1000)
  private String redirectUris;

  @Column(length = 1000)
  private String postLogoutRedirectUris;

  @Column(length = 1000)
  private String scopes;

  @Column(length = 2000)
  private String clientSettings;

  @Column(length = 2000)
  private String tokenSettings;
}

 

- 클래스를 만든 뒤에는 yaml 파일 설정을 아래와 같이 해서 자동으로 생성되게 하거나 따로 create table 쿼리를 이용해서 테이블을 만들어주면 된다.

 

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

 

2.  ClientRepository 를 만든다.

 

일반적인 JPA repository 를 만드는 방법과 다르지 않다.

 

ClientReposiroty 클래스를 만든다.

대신 조회할 때 increment id 대신 고유한 clientId 로 조회하기 때문에 String clientId 를 인자로 가지는 findByClientId 메서드를 만든다.

package com.server.authorization.repository;

import com.server.authorization.entity.ClientEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClientRepository extends JpaRepository<ClientEntity, String> {
  Optional<ClientEntity> findByClientId(String clientId);
}

 

3. 인가 작업에서 사용하기 위한 RegisteredClient 객체로 변환하는 Repository 를 만든다.

spring-security-oauth2-authorization-server 에서는 RegisteredClientReposiroty 인터페이스가 존재한다.

따라서 해당 인터페이스의 구현체를 만들어서 데이터베이스 테이블에서 조회한 raw 데이터를 인가에 필요한 RegisteredClient 객체로 컨버팅한다.

 

- RegisteredClientRepository 인터페이스

public interface RegisteredClientRepository {

	void save(RegisteredClient registeredClient);

	@Nullable
	RegisteredClient findById(String id);

	@Nullable
	RegisteredClient findByClientId(String clientId);
}

 

 

여기서 save 인터페이스는 위에서 언급한 JAVA 코드 예제에서 처럼 config 클래스에서 RegisteredClientRepository 빈을 등록하면서 사용할 수 있다.

@Bean
  public RegisteredClientRepository registeredClientRepository(JpaRegisteredClientRepository jpaRegisteredClientRepository) {

    // 클라이언트 정보를 등록하는 객체를 만든다.
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("clientid")
        .clientSecret("{noop}clientsecret")
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
        .redirectUri("http://127.0.0.1:8081")
        .scope("store")
        .scope("order")
        .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofSeconds(31536000)).build())
        .build();

    jpaRegisteredClientRepository.save(registeredClient); // 저장

    return jpaRegisteredClientRepository;
  }

 

- 구현체 JpaRegisteredClientRepository 클래스 만들기

클래스 명은 마음대로 명명해도 된다. 여기서는 JpaRegisteredClientRepository 라고 명명했다.

package com.server.authorization.repository;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.server.authorization.entity.ClientEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
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.jackson2.OAuth2AuthorizationServerJackson2Module;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

@Component
public class JpaRegisteredClientRepository implements RegisteredClientRepository {
  private final ClientRepository clientRepository;
  private final ObjectMapper objectMapper = new ObjectMapper();

  public JpaRegisteredClientRepository(ClientRepository clientRepository) {
    Assert.notNull(clientRepository, "clientRepository cannot be null");
    this.clientRepository = clientRepository;

    ClassLoader classLoader = JpaRegisteredClientRepository.class.getClassLoader();
    List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);
    this.objectMapper.registerModules(securityModules);
    this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
  }

  @Override
  public void save(RegisteredClient registeredClient) {
    Assert.notNull(registeredClient, "registeredClient cannot be null");
//    this.clientRepository.save(toEntity(registeredClient));
  }

  @Override
  public RegisteredClient findById(String id) {
    Assert.hasText(id, "id cannot be empty");
    return this.clientRepository.findById(id).map(this::toObject).orElse(null);
  }

  @Override
  public RegisteredClient findByClientId(String clientId) {
    Assert.hasText(clientId, "clientId cannot be empty");
    return this.clientRepository.findByClientId(clientId).map(this::toObject).orElse(null);
  }

  private RegisteredClient toObject(ClientEntity client) {
    Set<String> clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(
        client.getClientAuthenticationMethods());
    Set<String> authorizationGrantTypes = StringUtils.commaDelimitedListToSet(
        client.getAuthorizationGrantTypes());
    Set<String> redirectUris = StringUtils.commaDelimitedListToSet(
        client.getRedirectUris());
    Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(
        client.getPostLogoutRedirectUris());
    Set<String> clientScopes = StringUtils.commaDelimitedListToSet(
        client.getScopes());

    RegisteredClient.Builder builder = RegisteredClient.withId(client.getId().toString())
        .clientId(client.getClientId())
        .clientIdIssuedAt(client.getClientIdIssuedAt())
        .clientSecret(client.getClientSecret())
        .clientSecretExpiresAt(client.getClientSecretExpiresAt())
        .clientName(client.getClientName())
        .clientAuthenticationMethods(authenticationMethods ->
            clientAuthenticationMethods.forEach(authenticationMethod ->
                authenticationMethods.add(resolveClientAuthenticationMethod(authenticationMethod))))
        .authorizationGrantTypes((grantTypes) ->
            authorizationGrantTypes.forEach(grantType ->
                grantTypes.add(resolveAuthorizationGrantType(grantType))))
        .redirectUris((uris) -> uris.addAll(redirectUris))
        .postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
        .scopes((scopes) -> scopes.addAll(clientScopes));

    Map<String, Object> clientSettingsMap = parseMap(client.getClientSettings());
    builder.clientSettings(ClientSettings.withSettings(clientSettingsMap).build());

    Map<String, Object> tokenSettingsMap = parseMap(client.getTokenSettings());
    builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build());

    return builder.build();
  }

  private Map<String, Object> parseMap(String data) {
    try {
      return this.objectMapper.readValue(data, new TypeReference<Map<String, Object>>() {
      });
    } catch (Exception ex) {
      throw new IllegalArgumentException(ex.getMessage(), ex);
    }
  }

  private String writeMap(Map<String, Object> data) {
    try {
      return this.objectMapper.writeValueAsString(data);
    } catch (Exception ex) {
      throw new IllegalArgumentException(ex.getMessage(), ex);
    }
  }

  private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) {
    if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) {
      return AuthorizationGrantType.AUTHORIZATION_CODE;
    } else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) {
      return AuthorizationGrantType.CLIENT_CREDENTIALS;
    } else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {
      return AuthorizationGrantType.REFRESH_TOKEN;
    }
    return new AuthorizationGrantType(authorizationGrantType);              // Custom authorization grant type
  }

  private static ClientAuthenticationMethod resolveClientAuthenticationMethod(String clientAuthenticationMethod) {
    if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue().equals(clientAuthenticationMethod)) {
      return ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
    } else if (ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue().equals(clientAuthenticationMethod)) {
      return ClientAuthenticationMethod.CLIENT_SECRET_POST;
    } else if (ClientAuthenticationMethod.NONE.getValue().equals(clientAuthenticationMethod)) {
      return ClientAuthenticationMethod.NONE;
    }
    return new ClientAuthenticationMethod(clientAuthenticationMethod);      // Custom client authentication method
  }
}

 

위 코드는 예제일뿐 만약에 entity 데이터가 다른 식으로 구성되어있더라도 위 구현체에서 잘 맞게 컨버팅만 해주면 된다.

 

스프링 공식 홈페이지 예제에서 제공한 로직대로 save 하게 되면 아래와 같이 저장된다.

table plus / select * from client

다른 컬럼들은 우리가 알아보기 쉽게 컬럼에 저장되어있지만 client_settings, token_settings 같은 경우 json 모양의 오브젝트가 String 타입으로 컬럼에 저장되어있다. 

 

- tokens_settings 컬럼 값

{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.access-token-time-to-live":["java.time.Duration",31536000.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",3600.000000000],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.device-code-time-to-live":["java.time.Duration",300.000000000]}

 

해당 구조를 모르는 사람이 DB 를 컬럼 값을 수정해야한다거나 (토큰만료시간 수정이 제일 유력하다) 기존 security5 에서 권장하던 oauth2 client 의 필드대로 테이블이 구성되어있었다면 (우리가 그랬다...) 마이그레이션 작업을 진행할 때 위와같은 데이터 구조가 불가능 할 수 있다. 

 

따라서 Client 가 될 테이블 컬럼의 타입과 값들은 자유롭게 커스텀해주고 RegisteredClientRepository 의 구현체에서 RegisteredClient 로 컨버팅만 알맞게 해주면 된다. 

 

3. 그래서 어떻게 사용할 수 있는데?

2번 과정만 진행하면 실제로 OAuth2TokenEndpointFilter 에서 해당 client 를 조회하여 사용할 수 없다.

대신 위에서도 계속 언급해 왔듯이 설정 클래스에서 반드시 RegisteredClientRepository 를 빈으로 등록해주어야한다. 

 

- AuthorizationServerConfig.java (설정 코드 참고)

  @Bean
  @Order(Ordered.HIGHEST_PRECEDENCE)
  public SecurityFilterChain authorizationServerSecurityFilterChain(
      HttpSecurity http,
      JwtEncoder jwtEncoder,
      JpaRegisteredClientRepository registeredClientRepository,
      MemberService memberService,
      JwtCustomizer jwtCustomizer
  ) throws Exception {
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

    http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
        .registeredClientRepository(registeredClientRepository);

    http
        // Accept access tokens for User Info and/or Client Registration
        .oauth2ResourceServer(resourceServer -> resourceServer
            .jwt(Customizer.withDefaults()));

    return http.build();
  }

  @Bean
  public RegisteredClientRepository registeredClientRepository(JpaRegisteredClientRepository jpaRegisteredClientRepository) {
    return jpaRegisteredClientRepository;
  }
    피곤핑
    피곤핑

    티스토리툴바