ch14 Implementing an OAuth 2 authorization server

2024. 12. 16. 18:46Spring Security

이 챕터에는 다음을 다룹니다:

  • Implementing a Spring Security OAuth 2 authorization server
  • Using the authorization code and client credentials grant types
  • Configuring opaque and non-opaque access tokens
  • Using token revocation and introspection

 

13장에서 우리는 OAuth 2와 OpenID Connect에 대해 다루었습니다.
OAuth 2 명세[specification]를 기반으로 인증 및 권한 부여가 이루어지는 시스템에서 역할을 하는 주요 주체들에 대해 논의했으며, 권한 서버(Authorization Server)가 이 주체들 중 하나였습니다.
권한 서버의 역할은 사용자가 사용하는 애플리케이션(클라이언트)을 포함해 사용자를 인증하고, 백엔드에서 보호된 리소스에 접근하기 위한 인증 증명으로 작동하는 토큰을 발급하는 것입니다.
때로는 클라이언트가 사용자를 대신해 이러한 작업을 수행하기도 합니다.

Spring 생태계는 OAuth 2/OpenID Connect 권한 서버를 구현하기 위한 완전히 커스터마이징 가능한 방법을 제공합니다.
Spring Security 권한 서버는 현재 Spring을 사용해 권한 서버를 구현하는 데 사실상의 표준 방식으로 자리 잡았습니다.

이번 장에서는 이 프레임워크가 제공하는 주요 기능을 검토하고, 커스텀 권한 서버를 구현할 것입니다.
그림 14.1은 13장에서 논의했던 OAuth 2의 주체들과 권한 서버의 역할을 다시 상기시켜 주기 위해 삽입되었습니다.

Figure 14.1 OAuth 2에서의 주체들. 권한 서버는 사용자와 클라이언트의 세부 정보를 보호하며, 클라이언트가 리소스 서버 엔드포인트를 호출할 때 권한을 얻기 위해 사용할 수 있는 토큰을 발급합니다.

 

14.1절에서는 디폴트 구성을 사용하는 간단한 예제를 구현하는 것으로 시작합니다.
디폴트 구성은 권한 서버가 투명(non-opaque) 토큰을 발급한다는 것을 의미합니다.
14.2절에서는 권한 코드 그랜트 타입(authorization code grant type)으로 구현이 작동하는지 증명하고,
14.3절에서는 클라이언트 자격 증명 그랜트 타입(client credentials grant type)을 시연합니다.
14.4절에서는 권한 서버가 불투명(opaque) 토큰과 토큰 내부 검사(introspection)와 함께 작동하도록 설정하는 과정을 다룹니다.
이 장의 논의는 14.5절에서 토큰 철회(token revocation)를 다루며 마무리됩니다.

시작하기 전에, Spring Security를 사용해 권한 서버를 구현하는 방식이 과거와는 완전히 다르다는 점을 알려드립니다.
이 장에서는 새로운 접근 방식을 논의하지만, 기존에 업그레이드되지 않은 애플리케이션에서 작업해야 하는 경우,
이전 방식으로 권한 서버를 구현하는 방법을 알아야 할 수도 있습니다.

 

14.1 Implementing basic authentication using JSON web tokens

이 섹션에서는 Spring Security 권한 서버 프레임워크를 사용하여 basic OAuth 2 권한 서버를 구현합니다.
구성을 작동시키기 위해 필요한 주요 구성 요소들을 하나씩 살펴보고, 개별적으로 논의할 것입니다.
그 후, 권한 코드(authorization code)와 클라이언트 자격 증명(client credentials)이라는 두 가지 필수 OAuth 2 그랜트 타입을 사용해 애플리케이션을 테스트합니다.

권한 서버를 올바르게 설정하기 위해 필요한 주요 구성 요소는 다음과 같습니다:

  1. 프로토콜 엔드포인트를 위한 구성 필터
    • 권한 서버 기능[capabilities]에 특정한 구성을 정의하도록 돕는 필터입니다. 다양한 커스터마이징을 포함하며, 이에 대해서는 14.3절에서 논의합니다.
  2. 인증 구성 필터
    • Spring Security로 보호된 웹 애플리케이션과 유사하게, 인증 및 권한 부여 구성을 정의하기 위해 이 필터를 사용합니다.
      CORS(교차 출처 리소스 공유) 및 CSRF(교차 사이트 요청 위조)와 같은 기타 보안 메커니즘 구성도 정의합니다(2~10장에서 논의).
  3. UserDetailService 관리 구성 요소
    • Spring Security로 구현된 인증 과정과 마찬가지로, UserDetailsService 빈과 PasswordEncoder를 통해 설정됩니다.
      이들에 대해서는 3장과 4장에서 논의한 내용과 동일하게 동작합니다.
  4. 클라이언트 세부 정보 관리
    • 권한 서버는 RegisteredClientRepository라는 구성 요소를 사용하여 클라이언트 자격 증명 및 기타 세부 정보를 관리합니다.
  5. Key-Pairs(used to sign[서명] and validate tokens) 관리
    • 투명(non-opaque) 토큰을 사용하는 경우, 권한 서버는 private 키를 사용해 토큰을 서명합니다.
      리소스 서버가 토큰을 검증할 수 있도록 권한 서버는 public 키에 대한 접근도 제공합니다.
      권한 서버는 "key source" 구성 요소를 통해 private 키와 public 키 쌍을 관리합니다.
  6. 일반 앱 설정
    • AuthorizationServerSettings라는 구성 요소를 사용하여 애플리케이션이 노출하는 엔드포인트와 같은 일반적인 커스터마이징을 구성합니다.

 

그림 14.2는 최소한의 권한 서버 애플리케이션이 작동하기 위해 연결하고 구성해야 하는 구성 요소들을 보여줍니다.

Figure 14.2 Spring Security로 구현된 권한 서버가 작동하기 위해 구성하고 연결해야 하는 구성 요소들

 

먼저, 프로젝트에 필요한 의존성을 추가해야 합니다. 다음 코드 단편은 pom.xml 파일에 추가해야 하는 의존성을 보여줍니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>

 

구성은 일반적인 Spring 구성 클래스에서 작성합니다. 다음 코드 단편은 이를 보여줍니다:

@Configuration
public class SecurityConfig {
}

 

Spring 애플리케이션에서와 마찬가지로, 빈은 여러 구성 클래스에 정의할 수 있으며, 경우에 따라 스테레오타입 애노테이션을 사용해 정의할 수도 있습니다.
Spring 컨텍스트 관리에 대한 복습이 필요하다면, 제가 쓴 또 다른 책인 Spring Start Here(Manning, 2021)의 첫 부분을 참고하시기 바랍니다.

이제 listing 14.1을 살펴보겠습니다. 여기서는 프로토콜 엔드포인트를 위한 구성 필터를 보여줍니다.
applyDefaultSecurity() 메서드는 필요한 경우 나중에 재정의할 수 있는 최소 구성 세트를 정의하기 위해 사용하는 유틸리티 메서드입니다.
이 메서드를 호출한 후, listing에서는 OAuth2AuthorizationServerConfigurer 객체의 oidc() 메서드를 사용해 OpenID Connect 프로토콜을 활성화하는 방법을 보여줍니다.

또한, listing 14.1의 필터는 로그인 요청 시 사용자 리디렉션에 필요한 인증 페이지를 지정합니다.
우리 예제에서 권한 코드 그랜트 타입(authorization code grant type)을 활성화하려 하기 때문에 이 구성이 필요합니다.
Spring 웹 애플리케이션에서 기본 경로는 /login이며, 커스텀 경로를 설정하지 않는 한, 권한 서버 구성에서는 이 경로를 사용하게 됩니다.

 

Listing 14.1 Implementing the filter for configuring protocol endpoints

@Bean
@Order(1)
public SecurityFilterChain asFilterChain(HttpSecurity http)
	throws Exception {
    
    // 권한 서버 엔드포인트에 디폴트 구성을 적용하기 위해 유틸리티 메서드를 호출하기
	OAuth2AuthorizationServerConfiguration
		.applyDefaultSecurity(http);
        
	http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
		.oidc(Customizer.withDefaults()); // OpenID Connect 프로토콜 활성화
        
	http.exceptionHandling((e) ->
		e.authenticationEntryPoint(
                // 사용자를 위한 인증 페이지 지정
			new LoginUrlAuthenticationEntryPoint("/login"))
	);
    
	return http.build();
}

 

listing 14.2는 인증 및 권한 부여를 구성합니다. 이러한 구성은 일반적인 웹 애플리케이션과 유사하게 동작하며(2장에서 10장까지 논의한 내용과 같습니다),
listing 14.2에서는 최소 구성만 설정합니다:

  1. Form 로그인 인증을 활성화하여 애플리케이션이 사용자에게 간단한 로그인 페이지를 제공하고 인증할 수 있도록 함
  2. 애플리케이션이 모든 엔드포인트에 대해 인증된 사용자만 접근할 수 있도록 지정

여기에 작성할 수 있는 다른 구성으로는 인증 및 권한 부여 외에도 CSRF(9장에서 논의)나 CORS(10장에서 논의)와 같은 특정 보호 메커니즘에 대한 구성이 포함될 수 있습니다.

또한, listing 14.1과 14.2에서 사용된 @Order 애노테이션에도 주목하세요.
이 애노테이션은 애플리케이션 컨텍스트에서 여러 SecurityFilterChain 인스턴스를 구성할 때 필요합니다.
이는 구성에서 우선 순위를 지정해야 하기 때문입니다.

 

Listing 14.2 Implementing the filter for authorization configuration

@Bean
@Order(2) // 필터가 프로토콜 엔드포인트 필터 이후에 적용되도록 설정합니다.
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
	throws Exception {
    
    // Form 로그인 인증 방식을 활성화합니다.
	http.formLogin(Customizer.withDefaults());
    
    // 모든 엔드포인트가 인증을 요구하도록 구성합니다.
	http.authorizeHttpRequests(
		c -> c.anyRequest().authenticated()
	);
    
	return http.build();
}

 

만약 클라이언트가 사용자가 인증을 필요로 하는 그랜트 타입(예: 권한 코드 그랜트 타입)을 위해 여러분이 구축한 권한 서버를 사용하도록 예상된다면,
서버는 User Details를 관리해야 합니다! 다행히도, User Details 관리를 구현하기 위해 3장과 4장에서 배운 것과 동일한 접근 방식을 사용할 수 있습니다.
필요한 것은 UserDetailsServicePasswordEncoder 구현뿐입니다.

listing 14.3은 이 두 구성 요소의 정의를 보여줍니다.
이 예제에서는 UserDetailsService의 메모리 내(in-memory) 구현을 사용하지만, 3장에서 이를 사용자 정의하여 구현하는 방법을 배웠다는 점을 기억하세요.
대부분의 경우, 다른 웹 애플리케이션과 마찬가지로 이러한 세부 정보를 데이터베이스에 저장하게 됩니다. 따라서,
UserDetailsService 인터페이스를 커스터마이징하여 구현해야 합니다.

또한, 4장에서 NoOpPasswordEncoder는 학습용 샘플에서만 사용해야 한다고 논의한 내용을 기억하세요.
NoOpPasswordEncoder는 비밀번호를 변환하지 않고, 평문 상태로 남겨두며, 접근 권한이 있는 사람이라면 누구든지 확인할 수 있도록 합니다.
이는 바람직하지 않습니다. 항상 BCrypt와 같은 강력한 해시 함수를 사용하는 비밀번호 인코더를 사용해야 합니다.

 

Listing 14.3 Defining the user details management

@Bean
public UserDetailsService userDetailsService() {
	UserDetails userDetails = User.withUsername("bill")
								  .password("password")
								  .roles("USER")
                                  .build();
	return new InMemoryUserDetailsManager(userDetails);
}

@Bean
public PasswordEncoder passwordEncoder() {
	return NoOpPasswordEncoder.getInstance();
}

 

권한 서버는 client details를 관리하기 위해 RegisteredClientRepository 구성 요소가 필요합니다.
RegisteredClientRepository 인터페이스는 UserDetailsService와 유사하게 작동하지만, client details를 검색하도록 설계되었습니다.
이와 마찬가지로, 프레임워크는 RegisteredClient 객체를 제공하며, 이 객체는 권한 서버가 인식하는 클라이언트 애플리케이션을 설명하는 데 사용됩니다.

3장과 4장에서 배운 내용을 비유하자면, RegisteredClient는 클라이언트를 위한 UserDetails와 같으며,
RegisteredClientRepository는 User Details를 위한 UserDetailsService와 유사하게 client details를 처리합니다(Figure 14.3 참조).

이 예제에서는 권한 서버 구현의 전체적인 흐름에 집중할 수 있도록 메모리 내(in-memory) 구현을 사용합니다.
하지만 실제 애플리케이션에서는 데이터베이스에서 데이터를 가져올 수 있도록 이 인터페이스에 대한 구현을 제공해야 할 가능성이 큽니다.
이를 위해, 3장에서 UserDetailsService 인터페이스를 구현했던 것과 유사하게 RegisteredClientRepository 인터페이스를 구현하면 됩니다

Figure 14.3 client details를 관리하기 위해 RegisteredClientRepository 구현을 사용합니다.RegisteredClientRepository는 client details를 나타내기 위해 RegisteredClient 객체를 사용합니다.

 

다음 listing은 메모리 내 RegisteredClientRepository 빈의 정의를 보여줍니다.
이 메서드는 필요한 세부 정보로 하나의 RegisteredClient 인스턴스를 생성하고, 권한 서버에서 인증 시 사용할 수 있도록 메모리에 저장합니다.

 

Listing 14.4 Implementing client details management

@Bean
public RegisteredClientRepository registeredClientRepository() {

	// RegisteredClient 인스턴스 생성
	RegisteredClient registeredClient =
			RegisteredClient
				.withId(UUID.randomUUID().toString())
				.clientId("client")
				.clientSecret("secret")
				.clientAuthenticationMethod(
					ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
				.authorizationGrantType(
					AuthorizationGrantType.AUTHORIZATION_CODE)
				.redirectUri("https://www.manning.com/authorized")
				.scope(OidcScopes.OPENID)
				.build();
                
           // 메모리 내 RegisteredClientRepository 구현에서 관리되도록 추가합니다.
	return new InMemoryRegisteredClientRepository
		(registeredClient); // 
}

 

RegisteredClient 인스턴스를 생성할 때 지정한 세부 정보는 다음과 같습니다:

  • 고유 내부(unique internal) ID: 클라이언트를 고유하게 식별하는 값으로, 내부 애플리케이션 프로세스에서만 사용됩니다.
  • 클라이언트 ID: 사용자 이름과 유사한 외부 클라이언트 식별자입니다.
  • Client Secret: 사용자 비밀번호와 유사한 값입니다.
  • 클라이언트 인증 방법: 클라이언트가 액세스 토큰 요청을 보낼 때 권한 서버가 클라이언트 인증을 기대하는 방식을 나타냅니다.
  • 권한 부여 그랜트 타입(Authorization grant type): 이 클라이언트에 대해 권한 서버가 허용하는 그랜트 타입입니다. 클라이언트는 여러 그랜트 타입을 사용할 수 있습니다.
  • 리디렉션 URI: 권한 코드 그랜트 타입의 경우, 권한 서버가 클라이언트가 권한 코드를 제공받기 위해 리디렉션을 요청할 수 있도록 허용하는 URI 주소 중 하나입니다.
  • Scope: 액세스 토큰 요청의 목적을 정의합니다. 스코프는 나중에 권한 규칙에서 사용할 수 있습니다.

이 예제에서 클라이언트는 오직 권한 코드 그랜트 타입만 사용합니다.
그러나, 클라이언트가 여러 그랜트 타입을 사용하는 경우도 있을 수 있습니다.
클라이언트가 여러 그랜트 타입을 사용할 수 있도록 하려면, 다음 코드 단편에 표시된 것처럼 이를 지정해야 합니다.
여기 정의된 클라이언트는 authorization code, client credentials, 또는 refresh 토큰을 포함한 모든 그랜트 타입을 사용할 수 있습니다.

RegisteredClient registeredClient =
	RegisteredClient
		.withId(UUID.randomUUID().toString())
		.clientId("client")
		.clientSecret("secret")
		.clientAuthenticationMethod(
			ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
		.authorizationGrantType(
			AuthorizationGrantType.AUTHORIZATION_CODE)
		.authorizationGrantType(
			AuthorizationGrantType.CLIENT_CREDENTIALS)
		.authorizationGrantType(
			AuthorizationGrantType.REFRESH_TOKEN)
		.redirectUri("https://www.manning.com/authorized")
		.scope(OidcScopes.OPENID)
		.build();

 

마찬가지로, redirectUri() 메서드를 반복 호출하여 여러 개의 허용된 리디렉션 URI를 지정할 수 있습니다.
비슷하게, 클라이언트는 여러 개의 스코프에 접근할 수도 있습니다.
실제 애플리케이션에서는 이러한 모든 세부 정보를 데이터베이스에 저장하고, RegisteredClientRepository의 사용자 정의 구현을 통해 이를 검색합니다.

user와 client details를 갖추는 것 외에도, 권한 서버가 투명(non-opaque) 토큰을 사용하는 경우 key-pairs 관리 구성을 해야 합니다(13장에서 논의). 투명 토큰의 경우, 권한 서버는 private 키를 사용해 토큰에 서명하고, 클라이언트가 토큰의 진위 여부를 확인할 수 있도록 public 키를 제공합니다.

JWKSource는 Spring Security 권한 서버를 위한 키 관리를 제공하는 객체입니다.
listing 14.5는 애플리케이션 컨텍스트에서 JWKSource를 구성하는 방법을 보여줍니다.
이 예제에서는 키 쌍을 프로그래밍 방식으로 생성하고, 권한 서버가 사용할 수 있는 키 세트에 추가합니다.
실제 애플리케이션에서는 키를 안전하게 저장된 위치(예: 환경에 구성된 Vault)에서 읽어옵니다.

실제 시스템을 완벽히 재현하는 환경을 구성하는 것은 너무 복잡하므로,
권한 서버 구현에 집중할 수 있도록 하겠습니다.
그러나 실제 애플리케이션에서는 애플리케이션이 재시작될 때마다 새로운 키를 생성하는 것은 적합하지 않습니다(우리 예제처럼).
실제 애플리케이션에서 이런 일이 발생하면, 새 배포가 이루어질 때마다 기존에 발급된 토큰이 더 이상 작동하지 않게 됩니다
(기존 키로 유효성을 확인할 수 없기 때문).

따라서, 이 예제에서는 프로그래밍 방식으로 키를 생성하는 것이 적합하며, 이를 통해 권한 서버가 어떻게 작동하는지 시연할 수 있습니다. 하지만 실제 애플리케이션에서는 키를 안전한 장소에 보관하고, 해당 위치에서 키를 읽어와야 합니다.

 

Listing 14.5 Implementing the key pair set management

@Bean
public JWKSource<SecurityContext> jwkSource()
	throws NoSuchAlgorithmException {
    
    // RSA 암호화 알고리즘을 사용하여 public 키와 private 키 쌍을 프로그래밍 방식으로 생성하기
	KeyPairGenerator keyPairGenerator =
		KeyPairGenerator.getInstance("RSA");
        
	keyPairGenerator.initialize(2048);
	KeyPair keyPair = keyPairGenerator.generateKeyPair();
    
	RSAPublicKey publicKey =
		(RSAPublicKey) keyPair.getPublic();
        
	RSAPrivateKey privateKey =
		(RSAPrivateKey) keyPair.getPrivate();
        
	RSAKey rsaKey = new RSAKey.Builder(publicKey)
							  .privateKey(privateKey)
							  .keyID(UUID.randomUUID().toString())
							  .build();
                              
    // 발급된 토큰에 서명하기 위해 권한 서버가 사용하는 키 세트에 key pair 추가하기
	JWKSet jwkSet = new JWKSet(rsaKey);
    
    // 키 세트를 JWKSource 구현으로 감싸고 이를 Spring 컨텍스트에 추가하기 위해 리턴하기
	return new ImmutableJWKSet<>(jwkSet);
}

 

마지막으로, 최소 구성에 추가해야 할 마지막 구성 요소는 AuthorizationServerSettings 객체입니다(listing 14.6).

이 객체를 사용하면 권한 서버가 노출하는 모든 엔드포인트 경로를 커스터마이징할 수 있습니다.
다음 listing 과 같이 객체를 생성하면, 엔드포인트 경로는 기본값을 가지게 되며, 이에 대해서는 이 섹션에서 나중에 분석할 것입니다.

 

Listing 14.6 Configuring the authorization server generic settings

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
	return AuthorizationServerSettings.builder().build();
}

 

이제 애플리케이션을 시작하고 제대로 작동하는지 테스트할 수 있습니다.
14.2절에서는 권한 코드 플로우를 실행합니다.
그런 다음 14.3절에서는 권한 코드 구현을 통해 client credentials 플로우가 예상대로 작동하는지 테스트할 것입니다.

 

14.2 Running the authorization code grant type

이 섹션에서는 14.1절에서 구현한 권한 서버를 테스트합니다.
등록된 client details를 사용하여 권한 코드 플로우를 따라 액세스 토큰을 얻을 수 있을 것으로 예상합니다.
다음 단계를 따를 것입니다:

  1. 권한 서버가 노출하는 엔드포인트를 확인합니다.
  2. 권한 엔드포인트를 사용하여 권한 코드를 얻습니다.
  3. 권한 코드를 사용하여 액세스 토큰을 얻습니다.

첫 번째 단계는 권한 서버가 노출하는 엔드포인트 경로를 찾는 것입니다.
커스텀 경로를 구성하지 않았으므로 디폴트 값을 사용해야 합니다. 하지만 디폴트 값은 무엇일까요?
다음 코드 단편에 나오는 OpenID 구성 엔드포인트를 호출하여 이러한 세부 정보를 확인할 수 있습니다.
이 요청은 HTTP GET 메서드를 사용하며, 인증이 필요하지 않습니다:

http://localhost:8080/.well-known/openid-configuration

 

OpenID 구성 엔드포인트를 호출하면 다음 listing에 제시된 것과 유사한 응답을 받을 수 있습니다.

 

Listing 14.7 The response of the OpenID configuration request

{
	"issuer": "http://localhost:8080",
	"authorization_endpoint":
    // 클라이언트가 사용자를 인증하도록 리디렉션할 권한 엔드포인트
	"http://localhost:8080/oauth2/authorize",
    // 클라이언트가 액세스 토큰을 요청하기 위해 호출할 토큰 엔드포인트
	"token_endpoint": "http://localhost:8080/oauth2/token",
	"token_endpoint_auth_methods_supported": [
		"client_secret_basic",
		"client_secret_post",
		"client_secret_jwt",
		"private_key_jwt"
    ],
    // 리소스 서버가 토큰을 검증하기 위해 사용할 public 키를 가져오기 위해 호출할 키 세트 엔드포인트
	"jwks_uri": "http://localhost:8080/oauth2/jwks",
	"userinfo_endpoint": "http://localhost:8080/userinfo",
	"response_types_supported": [
		"code"
	],
	"grant_types_supported": [
		"authorization_code",
		"client_credentials",
		"refresh_token"
	],
	"revocation_endpoint": "http://localhost:8080/oauth2/revoke",
	"revocation_endpoint_auth_methods_supported": [
		"client_secret_basic",
		"client_secret_post",
		"client_secret_jwt",
		"private_key_jwt"
	],
	"introspection_endpoint":
    // 리소스 서버가 불투명(opaque) 토큰을 검증하기 위해 호출할 토큰 내부 검사(introspection) 엔드포인트
	"http://localhost:8080/oauth2/introspect",
	"introspection_endpoint_auth_methods_supported": [
		"client_secret_basic",
		"client_secret_post",
		"client_secret_jwt",
		"private_key_jwt"
	],
	"subject_types_supported": [
		"public"
	],
	"id_token_signing_alg_values_supported": [
		"RS256"
	],
	"scopes_supported": [
		"openid"
	]
}

 

Figure 14.4를 살펴보며 13장에서 논의한 권한 코드 플로우를 다시 떠올려 봅시다.
이제 이를 사용하여 우리가 구축한 권한 서버가 제대로 작동하는지 시연할 것입니다.

우리 예제에는 클라이언트가 없기 때문에, 클라이언트처럼 행동해야 합니다.
권한 엔드포인트를 이미 알고 있으므로, 클라이언트가 사용자를 리디렉션하는 것처럼 브라우저 주소창에 이를 입력하여 시뮬레이션할 수 있습니다.
다음 url은 권한 요청을 보여줍니다:

http://localhost:8080/oauth2/authorize?response_type=code&client_id=client&scope=openid&redirect_uri=https://www.manning.com/authorized&code_challenge=PhoF2u3de1pemIkXlUZ59DKXPAxGHDDwtrum5WRpZqI&code_challenge_method=S256

 

Figure 14.4 권한 코드 그랜트 타입. 인증에 성공한 후, 클라이언트는 권한 코드를 받습니다. 이 코드는 클라이언트가 액세스 토큰을 얻는 데 사용되며, 이를 통해 리소스 서버가 보호하는 리소스에 접근할 수 있게 됩니다.

 

권한(authorization) 요청에서는 몇 가지 파라미터를 추가한 것을 볼 수 있습니다:

  • response_type=code
    • 이 요청 파라미터는 클라이언트가 권한 코드 그랜트 타입을 사용하고자 한다는 것을 권한 서버에 알립니다.
    • 클라이언트는 여러 그랜트 타입을 구성했을 수 있으며, 사용하고자 하는 그랜트 타입을 권한 서버에 알려야 합니다.
  • client_id=client
    • 클라이언트 식별자는 usen의 "user name"과 유사합니다.
    • 시스템에서 클라이언트를 고유하게 식별합니다.
  • scope=openid
    • 이 인증 시도로 클라이언트가 부여받고자 하는 스코프를 지정합니다.
  • redirect_uri=https://www.manning.com/authorized
    • 인증에 성공한 후 권한 서버가 리디렉션할 URI를 지정합니다.
    • 이 URI는 현재 클라이언트에 대해 이전에 구성된 URI 중 하나여야 합니다.
  • code_challenge=…
    • PKCE로 강화된 권한 코드를 사용하는 경우(13장에서 논의),
      권한 요청 시 코드 챌린지를 제공해야 합니다.
      클라이언트는 토큰을 요청할 때 검증자(verifier) 쌍을 보내
      초기 요청을 보낸 애플리케이션과 동일한 애플리케이션임을 증명해야 합니다.
      PKCE 플로우는 디폴트로 활성화되어 있습니다.
  • code_challenge_method=S256
    • 이 요청 파라미터는 검증자로부터 챌린지를 생성할 때 사용된 해싱 방법을 지정합니다.
    • 여기서 S256은 SHA-256이 해시 함수로 사용되었음을 의미합니다.
 
저는 PKCE를 사용하는 권한 코드 그랜트 타입을 권장하지만,
PKCE 플로우 강화를 비활성화해야 하는 상황이 있다면, 다음 코드 단편과 같이 설정할 수 있습니다.
clientSettings() 메서드를 확인하세요. 이 메서드는 ClientSettings 인스턴스를 받아,
코드 교환을 위한 증명 키(proof key)를 비활성화할 수 있도록 설정을 지정할 수 있습니다.
RegisteredClient registeredClient = RegisteredClient
									.withId(UUID.randomUUID().toString())
									.clientId("client")
									// …
									.clientSettings(ClientSettings.builder()
											.requireProofKey(false)
											.build())
											.build();
 
이 예제에서는 권한 코드와 PKCE를 사용하는 방식을 시연합니다. 이는 디폴트 값이며 권장되는 방식입니다.
브라우저 주소창을 통해 권한 요청을 보내면 Figure 14.4의 2단계를 시뮬레이션할 수 있습니다.
권한 서버는 우리를 로그인 페이지로 리디렉션하며, 여기에서 사용자 이름과 비밀번호를 사용하여 인증할 수 있습니다.
이는 Figure 14.4의 3단계에 해당합니다.
Figure 14.5는 권한 서버가 사용자에게 제공하는 로그인 페이지를 보여줍니다.
 
Figure 14.5 권한 요청에 대한 응답으로 권한 서버가 사용자에게 제공하는 로그인 페이지.

 

우리 구현에서는 하나의 사용자만 있습니다(listing 14.3 참조).
사용자의 자격 증명은 사용자 이름 bill과 비밀번호 password입니다.
사용자가 올바른 자격 증명을 입력하고 Sign In 버튼을 선택하면, 권한 서버는 요청된 리디렉션 URI로 사용자를 리디렉션하고 권한 코드를 제공합니다
(Figure 14.6에 표시된 대로, Figure 14.4의 4단계).

 

Figure 14.6 인증에 성공한 후, 권한 서버는 사용자를 지정된 리디렉트 URI로 안내하고 인증 코드를 발급합니다. 클라이언트는 이 코드를 사용하여 액세스 토큰을 획득합니다.

 

클라이언트가 권한 코드를 획득한 후, 액세스 토큰을 요청할 수 있습니다.
클라이언트는 토큰 엔드포인트를 사용하여 액세스 토큰을 요청할 수 있습니다.
다음은 Postman을 사용한 토큰 요청을 보여줍니다. 이 요청은 HTTP POST 메서드를 사용합니다.
클라이언트를 등록할 때 HTTP Basic 인증이 필요하다고 지정했기 때문에,
토큰 요청은 클라이언트 ID와 비밀(client/secret)을 사용한 HTTP Basic 인증이 필요합니다.

http://localhost:8080/oauth2/token?client_id=client&redirect_uri=https://www.manning.com/authorized&grant_type=authorization_code&code=…&code_verifier=oNkDj-PNvUGO9v-peGwGT-LbNnrewm-vTfnjbPaziwY

※ 위 url 중에 code 파라미터의 값은 생략(...)하였습니다. 그러므로 OAuth 2 서버가 제공한 인가 코드를 적용해야 합니다

 

Postman의 Authorization을 다음과 같이 설정하고 access/id 토큰을 얻습니다

 

만약 url 길이가 부담이 된다면, 다음과 같이 form-urlencoded 방식으로 토큰 요청을 수행할 수 있습니다.

또한 Authroziation 헤더에 다음과 같이 직접 Base64 인코딩된 값을 Basic 첨자와 함께 설정합니다. 

 

url의 파라미터를 form 형식의 url 인코딩 방식을 적용하였습니다.

 

요청에 사용된 파라미터는 다음과 같습니다:

  • client_id=client
    • 클라이언트를 식별하는 데 필요합니다.
  • redirect_uri=https://www.manning.com/authorized
    • 사용자 인증에 성공한 후 권한 서버가 권한 코드를 제공한 리디렉션 URI입니다.
  • grant_type=authorization_code
    • 클라이언트가 액세스 토큰을 요청하기 위해 사용하는 플로우를 나타냅니다.
  • code=ao2oz47zdM0D5…
    • 권한 서버가 클라이언트에게 제공한 권한 코드의 값입니다.
  • code_verifier=qPsH306-ZDD…
    • 클라이언트가 권한 요청 시 보낸 챌린지를 기반으로 생성된 검증자입니다.

참고 모든 세부 사항에 주의를 기울이세요.
애플리케이션이 알고 있는 값이나 권한 요청에서 보낸 값 중 하나라도 일치하지 않으면,
토큰 요청이 실패합니다. 또한 한 번 사용한 challenge/verifier 값은 재사용할 수 없습니다.

 

다음 코드 단편은 토큰 요청의 응답 본문을 보여줍니다.
이제 클라이언트는 리소스 서버에 요청을 보낼 때 사용할 수 있는 액세스 토큰을 획득했습니다.

{
	"access_token": "eyJraWQiOiI4ODlhNGFmO…",
	"scope": "openid",
	"id_token": "eyJraWQiOiI4ODlhNGFmOS1…",
	"token_type": "Bearer",
	"expires_in": 299
}
 
 
우리는 OpenID Connect 프로토콜을 활성화했기 때문에 OAuth 2만 사용하는 것이 아니라,
토큰 응답에 ID 토큰도 포함됩니다.
만약 클라이언트가 갱신 토큰(refresh token) 그랜트 타입을 사용하도록 등록되었다면, refresh 토큰도 생성되어 응답을 통해 전송되었을 것입니다.

 


Generating the code verifier and challenge

이 섹션에서 작업한 예제에서는 PKCE를 사용하는 권한 코드를 사용했습니다.
권한 요청과 토큰 요청에서, 미리 생성해둔 챌린지 값과 검증자 값을 사용했습니다.
이 값들에 대해 깊이 신경 쓰지 않았는데, 이는 클라이언트의 역할이며, 권한 서버나 리소스 서버가 생성하는 것이 아니기 때문입니다.
실제 애플리케이션에서는 JavaScript나 모바일 앱이 OAuth 2 플로우에서 이 두 값을 생성해야 합니다.

그러나 궁금하다면, 이 두 값을 어떻게 생성했는지 이 사이드바에서 설명하겠습니다.

코드 검증자(code verifier)는 32바이트의 랜덤 데이터입니다.
HTTP 요청을 통해 쉽게 전송할 수 있도록, 이 데이터는 URL 인코더를 사용해 Base64로 인코딩되며, 패딩은 포함하지 않습니다.
다음 코드 단편은 Java에서 이를 수행하는 방법을 보여줍니다:

SecureRandom secureRandom = new SecureRandom();
byte [] code = new byte[32];
secureRandom.nextBytes(code);
String codeVerifier = Base64.getUrlEncoder()
	.withoutPadding()
	.encodeToString(code);

 

코드 검증자(code verifier)를 생성한 후에는 해시 함수를 사용해 챌린지를 생성합니다.
다음 코드 단편은 SHA-256 해시 함수를 사용하여 챌린지를 생성하는 방법을 보여줍니다.
검증자와 마찬가지로, 바이트 배열을 문자열 값으로 변환하여 HTTP 요청을 통해 쉽게 전송할 수 있도록
Base64를 사용해야 합니다:

MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");

byte [] digested = messageDigest.digest(verifier.getBytes());
String codeChallenge = Base64.getUrlEncoder()
	.withoutPadding()
	.encodeToString(digested);

 

이제 검증자(verifier)와 챌린지(challenge)를 생성했습니다.
이 값을 이 섹션에서 논의한 대로 권한 요청과 토큰 요청에 사용할 수 있습니다.


14.3 Running the client credentials grant type

이 섹션에서는 14.1절에서 구현한 권한 서버를 사용하여 클라이언트 자격 증명 그랜트 타입(client credentials grant type)을 시도해 보겠습니다.
클라이언트 자격 증명 그랜트 타입은 사용자의 인증이나 동의 없이 클라이언트가 액세스 토큰을 받을 수 있도록 허용하는 플로우입니다.

가능하면 사용자 종속적인 그랜트 타입(예: 권한 코드)과 클라이언트 독립적인 그랜트 타입(예: 클라이언트 자격 증명)을 동시에 사용할 수 있는 클라이언트를 허용하지 않는 것이 좋습니다.

15장에서 리소스 서버를 논의할 때 배우겠지만, 권한 구현은
권한 코드 그랜트 타입으로 얻은 액세스 토큰과 클라이언트 자격 증명 그랜트 타입으로 얻은 액세스 토큰의 차이를 구분하지 못할 수 있습니다.
따라서 이러한 경우에는 별도의 등록을 사용하는 것이 가장 좋으며, 가능하면 서로 다른 스코프를 통해 토큰 사용을 구분하는 것이 바람직합니다.

listing 14.8은 클라이언트 자격 증명 그랜트 타입을 사용할 수 있도록 등록된 클라이언트를 보여줍니다.
여기에서 다른 스코프도 구성한 것을 볼 수 있습니다. 이 경우, CUSTOM은 제가 선택한 이름일 뿐이며,
스코프에 대해 원하는 이름을 선택할 수 있습니다.
선택한 이름은 일반적으로 스코프의 목적을 더 쉽게 이해할 수 있도록 만들어야 합니다.

예를 들어, 이 애플리케이션이 리소스 서버의 가용 상태(liveness state)를 확인하기 위해
클라이언트 자격 증명 그랜트 타입을 사용해 토큰을 얻어야 한다면,
스코프 이름을 LIVENESS로 지정하는 것이 더 명확할 수 있습니다.

 

Listing 14.8 Configuring a registered client for the client credentials grant type

@Bean
public RegisteredClientRepository registeredClientRepository() {
	RegisteredClient registeredClient =
		RegisteredClient.withId(UUID.randomUUID().toString())
			.clientId("client")
			.clientSecret("secret")
			.clientAuthenticationMethod(
            	// Allowing the registered client to use the client credentials grant type
				ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
			.scope("CUSTOM") // Configuring a scope to match the purpose for the access token request
			.build();
        
	return new InMemoryRegisteredClientRepository(registeredClient);
}

 

그림 14.7은 13장에서 논의한 클라이언트 자격 증명 플로우를 보여줍니다.
클라이언트는 액세스 토큰을 얻기 위해 간단히 요청을 보내고, 자신의 자격 증명(클라이언트 ID와 비밀)을 사용해 인증합니다.

 

Figure 14.7 The client credentials grant type. 애플리케이션은 클라이언트 자격 증명만으로 인증하여 액세스 토큰을 얻을 수 있습니다.

 

다음 코드 단편은 cURL을 사용한 토큰 요청을 보여줍니다.

# curl -X POST 'http://localhost:8080/oauth2/token?grant_type=client_credentials&scope=CUSTOM' --header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='

 

14.2절에서 권한 코드 그랜트 타입을 실행할 때 사용한 요청과 비교하면, 이 요청이 더 단순하다는 것을 알 수 있습니다.
클라이언트는 클라이언트 자격 증명 그랜트 타입을 사용하며, 토큰을 요청할 스코프만 명시하면 됩니다.
클라이언트는 HTTP Basic 인증을 통해 자신의 자격 증명을 사용하여 요청합니다.

다음 코드 단편은 요청된 액세스 토큰이 포함된 HTTP 응답 본문을 보여줍니다.

{
	"access_token": "eyJraWQiOiI4N2E3YjJiNS…",
	"scope": "CUSTOM",
	"token_type": "Bearer",
	"expires_in": 300
}

 

 

14.4 Using opaque tokens and introspection

이번 장에서는 권한 코드 그랜트 타입(14.2절)과 클라이언트 자격 증명 그랜트 타입(14.3절)을 시연했습니다.
이 두 가지 경우 모두 투명(non-opaque) 액세스 토큰을 얻을 수 있도록 클라이언트를 구성할 수 있었습니다.
하지만 클라이언트를 불투명(opaque) 토큰을 사용하도록 쉽게 구성할 수도 있습니다.

이 섹션에서는 등록된 클라이언트를 불투명 토큰을 얻도록 구성하는 방법과 권한 서버가 불투명 토큰 검증을 돕는 방법을 보여드리겠습니다.

listing 14.9는 불투명 토큰을 사용하도록 등록된 클라이언트를 구성하는 방법을 보여줍니다.
불투명 토큰은 어떤 그랜트 타입과도 함께 사용할 수 있다는 점을 기억하세요.
이 섹션에서는 논의 중인 주제에 집중할 수 있도록 클라이언트 자격 증명 그랜트 타입을 사용하여 단순하게 유지할 것입니다.
또한, 권한 코드 그랜트 타입을 사용해 불투명 토큰을 생성할 수도 있습니다.

 

Listing 14.9 Configuring clients to use opaque tokens

@Bean
public RegisteredClientRepository registeredClientRepository() {
	RegisteredClient registeredClient =
		RegisteredClient.withId(UUID.randomUUID().toString())
			.clientId("client")
			.clientSecret("secret")
			.clientAuthenticationMethod(
				ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
			.tokenSettings(TokenSettings.builder()
            // Configuring the client to use opaque access tokens
			.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
			.build())
			.scope("CUSTOM")
			.build();
            
	return new InMemoryRegisteredClientRepository(registeredClient);
}

 

14.3절에서 배운 것처럼 액세스 토큰을 요청하면, 불투명(opaque) 토큰을 받게 됩니다.
이 토큰은 더 짧고 데이터를 포함하지 않습니다. 다음 코드 단편은 액세스 토큰을 요청하는 cURL 요청입니다:

 curl -X POST 'http://localhost:8080/oauth2/token?grant_type=client_credentials&scope=CUSTOM' --header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='

 

다음 코드 단편은 투명(non-opaque) 토큰을 예상했을 때 받았던 응답과 유사한 결과를 보여줍니다.
유일한 차이점은 토큰 자체로, 이제는 JWT 토큰이 아니라 불투명(opaque) 토큰입니다.

{
	"access_token": "iED8-...",
	"scope": "CUSTOM",
	"token_type": "Bearer",
	"expires_in": 299
}

 

다음 코드 단편은 완전한 불투명(opaque) 토큰의 예를 보여줍니다.
이 토큰은 훨씬 짧고, JWT와 같은 구조를 가지고 있지 않다는 점에 주목하세요(점(.)으로 구분된 세 부분이 없습니다).

iED8-aUd5QLTfihDOTGUhKgKwzhJFzY
➥WnGdpNT2UZWO3VVDqtMONNdozq1
➥r9r7RiP0aNWgJipcEu5HecAJ75V
➥yNJyNuj-kaJvjpWL5Ns7Ndb7Uh6
➥DI6M1wMuUcUDEjJP

 

불투명(opaque) 토큰은 데이터를 포함하지 않기 때문에,
권한 서버가 생성한 클라이언트(및 잠재적으로 사용자)에 대한 세부 정보를 어떻게 검증하고 얻을 수 있을까요?
가장 쉽고(또한 가장 많이 사용되는) 방법은 권한 서버에 직접 요청하는 것입니다.

권한 서버는 요청과 함께 토큰을 보낼 수 있는 엔드포인트를 제공합니다.
권한 서버는 이 요청에 대해 토큰에 대한 필요한 세부 정보를 응답합니다.
이 과정을 토큰 내부 검사(Introspection)라고 합니다(Figure 14.8 참조).

Figure 14.8 토큰 내부 검사(Introspection). 불투명(opaque) 토큰을 사용하는 경우, 리소스 서버는 권한 서버에 요청을 보내 토큰이 유효한지와 해당 토큰이 누구에게 발급되었는지에 대한 세부 정보를 확인해야 합니다.

 

다음 코드 단편은 권한 서버가 제공하는 내부 검사(introspection) 엔드포인트에 대한 cURL 호출을 보여줍니다.
클라이언트는 요청을 보낼 때 자신의 자격 증명을 사용해 HTTP Basic 인증을 해야 합니다.
클라이언트는 요청 파라미터로 토큰을 전송하며, 응답으로 토큰에 대한 세부 정보를 받습니다:

curl -X POST 'http://localhost:8080/oauth2/introspect?token=iED8-…' --header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='
 

다음 코드 단편은 유효한 토큰에 대한 내부 검사 요청 응답의 예를 보여줍니다.
토큰이 유효하면, 상태가 active로 표시되며,
응답에는 권한 서버가 해당 토큰에 대해 알고 있는 모든 세부 정보가 포함됩니다.

{
	"active": true,
	"sub": "client",
	"aud": [
		"client"
	],
	"nbf": 1682941720,
	"scope": "CUSTOM",
	"iss": "http://localhost:8080",
	"exp": 1682942020,
	"iat": 1682941720,
	"jti": "ff14b844-1627-4567-8657-bba04cac0370",
	"client_id": "client",
	"token_type": "Bearer"
}

 

토큰이 존재하지 않거나 만료된 경우, active 상태는 false로 표시됩니다.
다음 코드 단편은 이를 보여줍니다:

{
	"active": false,
}

 

토큰의 디폴트 활성 시간은 300초입니다.
예제에서는 토큰의 수명을 더 길게 설정하는 것이 좋습니다.
그렇지 않으면 토큰을 테스트에 사용할 시간이 부족해져서 불편할 수 있습니다.

listing 14.10은 토큰의 수명을 변경하는 방법을 보여줍니다.
예제에서는 수명을 매우 길게 설정하는 것을 선호합니다(이 경우 12시간).
그러나 실제 애플리케이션에서는 이렇게 긴 수명으로 설정하지 않아야 한다는 점을 기억하세요.
실제 애플리케이션에서는 일반적으로 10분에서 최대 30분 정도의 수명을 설정합니다.

 

Listing 14.10 Changing the access token time to live

RegisteredClient registeredClient = RegisteredClient
				.withId(UUID.randomUUID().toString())
				.clientId("client")
				// …
				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
				.tokenSettings(TokenSettings.builder()
				.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
                // Setting 12 hours as the access token time to live
                .accessTokenTimeToLive(Duration.ofHours(12))
				.build())
				.scope("CUSTOM")
				.build();

 

 

14.5 Revoking tokens

토큰이 도난당한 것을 발견했다고 가정해봅시다.
도난당한 토큰을 어떻게 사용 불가능하게 만들 수 있을까요?
토큰 철회(Token Revocation)는 권한 서버가 이전에 발급한 토큰을 무효화하는 방법입니다.
일반적으로 액세스 토큰의 수명은 짧기 때문에, 토큰이 도난당하더라도 사용하기가 어렵습니다.
하지만 경우에 따라 추가적인 주의가 필요할 수 있습니다.

다음 코드 단편은 권한 서버가 제공하는 토큰 철회 엔드포인트에 요청을 보내는 cURL 명령을 보여줍니다.
이번 장에서 작업한 프로젝트 중 어느 것이든 테스트에 사용할 수 있습니다.
Spring Security 권한 서버에서는 디폴트로 철회 기능이 활성화되어 있습니다.
이 요청에는 철회하려는 토큰과 클라이언트 자격 증명을 사용한 HTTP Basic 인증만 필요합니다.
요청을 보내면 해당 토큰은 더 이상 사용할 수 없게 됩니다:

curl -X POST 'http://localhost:8080/oauth2/revoke?token=N7BruErWm-44-…' --header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='

 

철회한 토큰으로 내부 검사(introspection) 엔드포인트를 사용하면,
수명이 만료되지 않았더라도 철회 후에는 해당 토큰이 더 이상 활성화되지 않은 상태임을 확인할 수 있습니다:

curl -X POST 'http://localhost:8080/oauth2/introspect?token=N7BruErWm-44-…' --header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='

 

토큰 철회를 사용하는 것이 의미가 있는 경우도 있지만, 항상 필요한 것은 아닙니다.
철회 기능을 사용하려면 모든 호출에서 토큰이 여전히 활성 상태인지 확인하기 위해
내부 검사(introspection)를 사용해야 한다는 점을 기억하세요(투명(non-opaque) 토큰의 경우에도 마찬가지).
내부 검사를 자주 사용하면 성능에 큰 영향을 미칠 수 있습니다.
따라서 항상 스스로에게 물어보아야 합니다: "이 추가적인 보호 계층이 정말 필요한가?"

1장에서 논의한 내용을 기억하세요.
어떤 때는 키를 러그 아래 숨기는 것으로 충분하지만,

( 키를 러그 아래 숨기는 것으로 충분의 의미: 복잡한 시스템이 필요하지 않을 때 간단한 해결책으로도 충분 )
다른 때는 고급스럽고 복잡하며 비용이 많이 드는 경보 시스템이 필요할 수도 있습니다.
무엇을 사용할지는 무엇을 보호하려는지에 따라 달라집니다.