ch15 Implementing an OAuth 2 resource server

2024. 12. 17. 19:57Spring Security

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

  • Implementing a Spring Security OAuth 2 resource server
  • Using JWT tokens with custom claims
  • Configuring introspection for opaque tokens or revocation
  • Implementing more complex scenarios and multitenancy

이 챕터에서는 OAuth 2 시스템에서 백엔드 애플리케이션을 보호하는 방법에 대해 설명합니다. OAuth 2 용어에서 리소스 서버라고 부르는 것은 단순히 백엔드 서비스입니다. 14장에서 Spring Security를 사용하여 OAuth 서버 역할을 구현하는 방법을 배웠다면, 이제 OAuth 서버가 생성한 토큰을 사용하는 방법에 대해 논의할 차례입니다.

실제 시나리오에서는 우리가 14장에서 했던 것처럼 맞춤형 OAuth 서버를 구현할 수도 있고 그렇지 않을 수도 있습니다. 많은 조직이 맞춤형 소프트웨어를 개발하는 대신 타사 솔루션을 사용할 수 있습니다. Keycloak과 같은 오픈 소스 솔루션에서 Okta, Cognito, Azure AD와 같은 엔터프라이즈 제품까지 다양한 대안이 존재합니다.

OAuth  서버를 직접 구현하지 않고도 설정하는 옵션이 있지만, 백엔드에서 인증 및 권한 부여를 제대로 구현해야 합니다. 그런 이유로 이 챕터는 필수적이라고 생각합니다. 이 챕터를 읽으면서 배우는 기술은 업무에 도움이 될 확률이 높습니다. 그림 15.1은 OAuth 2의 주요 구성 요소와 학습 계획에서 우리가 어디에 있는지 다시 상기시켜 줍니다.

Figure 15.1 In OAuth 2, the app’s backend is called a resource server because it protects the users’
and clients’ resources (data and actions that can be done on data).

 

이 챕터의 15.1에서는 JSON 웹 토큰(JWT)에 대한 리소스 서버 구성을 논의하는 것으로 시작합니다. 오늘날 OAuth 2 시스템에서는 대부분 JWT가 사용되기 때문에 이를 먼저 다룹니다. 15.2에서는 JWT를 커스터마이징하고 본문(body)이나 헤더의 클레임에 커스텀 값을 사용하는 방법을 논의합니다.

15.3에서는 토큰 유효성 검사를 위해 리소스 서버를 인트로스펙션(introspection)으로 설정하는 방법을 다룹니다. 인트로스펙션 프로세스는 불투명 토큰(opaque tokens)을 사용할 때나 만료일 전에 시스템이 토큰을 무효화할 수 있도록 해야 할 때 유용합니다.

마지막으로 15.4에서는 멀티테넌시와 같은 고급 구성 사례를 논의하며 이 챕터를 마무리합니다.

 

15.1 Configuring JWT validation

이 섹션에서는 리소스 서버가 JWT(JSON Web Tokens)를 검증하고 사용하는 방법을 구성하는 방법에 대해 논의합니다. JWT는 투명(non-opaque) 토큰으로, 리소스 서버가 권한 부여에 사용하는 데이터를 포함하고 있습니다.

JWT를 사용하려면 리소스 서버는 두 가지를 증명해야 합니다:

  1. JWT가 인증되었다는 것, 즉, 예상된 OAuth 서버가 실제로 이 토큰을 사용자 및/또는 클라이언트 인증의 증거로 발급했음을 확인해야 합니다.
  2. 토큰 내 데이터를 읽고 이를 사용해 권한 부여 규칙을 구현해야 합니다.

리소스 서버를 실제로 구현하고 처음부터 구성하는 방법을 배우면서 이를 구성하는 방법을 익히겠습니다. 새로운 Spring Boot 프로젝트를 생성하고 필요한 종속성(dependencies)을 추가하는 것부터 시작합니다. 그런 다음 데모용 엔드포인트(테스트를 위한 리소스)를 구현하고, 인증 및 권한 부여를 위한 구성을 작업합니다.

다음은 우리가 따라갈 단계입니다:

  1. 프로젝트에 필요한 종속성 추가 (Maven을 사용하므로 pom.xml 파일에 추가).
  2. 테스트를 위한 더미 엔드포인트 선언.
  3. JWT 인증 구현: 서비스를  public key URI(public key set URI)로 구성.
  4. 권한 부여 규칙 구현.
  5. 구현 테스트:
    a. OAuth 서버를 사용해 토큰 생성.
    b. 생성한 토큰을 사용하여 2단계에서 만든 더미 엔드포인트 호출.

다음 단편 조각은 필요한 종속성을 보여줍니다. WebSpring Security 종속성 외에 resource server starter도 추가할 것입니다.

Listing 15.1 Dependencies for implementing a resource server

<!-- 리소스 서버 스타터는 애플리케이션을 OAuth 2 리소스 서버로 구현하는 데 필요한 종속성을 제공합니다 -->
<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-security</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>

 

종속성을 설정한 후, 구현을 테스트하기 위해 사용할 더미 엔드포인트를 생성합니다. 다음 단편 조각은 /demo 경로에 엔드포인트를 노출하는 간단한 컨트롤러를 보여줍니다.

 

Listing 15.2 Declaring a simple endpoint for test purposes

@RestController
public class DemoController {
	@GetMapping("/demo")
	public String demo() {
		return "Demo";
	}
}

 

이 예제를 실행하려면 OAuth 서버가 필요합니다. 14장에서 생성한 ch14-ex1 프로젝트의 OAuth 서버를 사용할 수 있습니다.

OAuth 서버와 리소스 서버를 동일한 시스템에서 동시에 실행하려면 서로 다른 포트를 구성해야 합니다. OAuth 서버가 기본 포트 8080을 사용하기 때문에, 리소스 서버의 포트를 다른 값으로 변경할 수 있습니다. 여기서는 9090으로 변경했지만, 시스템에서 사용할 수 있는 다른 빈 포트를 선택해도 됩니다.

다음 단편 조각은 application.properties 파일에 포트를 변경하기 위해 추가할 설정입니다:

server.port=9090

 

ch14-ex1 프로젝트의 OAuth 서버와 현재 애플리케이션을 모두 시작하세요. 이 예제는 책에서 제공하는 프로젝트 중 ch15-ex1에서 확인할 수 있습니다.

14장에서 설명한 것처럼 OpenID Connect OAuth 서버구성 정보(권한 부여 URL, 토큰 URL, public key 세트 URL 등)를 가져올 수 있는 URL을 노출합니다. 이 URL을 well-known URL이라고 부릅니다. 다음은 해당 URL의 예시입니다:

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

 

이 링크를 사용하면 OAuth 서버가 노출하는 Public Key 세트 URL에 대한 정보를 얻을 수 있습니다. 리소스 서버는 이 엔드포인트를 호출하여 Public Key 세트를 가져와야 합니다. 그런 다음 리소스 서버는 이 키들 중 하나를 사용해 액세스 토큰의 서명을 검증합니다 (그림 15.2).

Figure 15.2 리소스 서버는 OAuth 서버가 제공하는 엔드포인트를 통해 Public Key 세트를 가져옵니다. 그런 다음 리소스 서버는 이 키를 사용하여 액세스 토큰의 서명을 검증합니다.

 

listing 15.3OAuth 서버가 노출하는 well-known configuration 엔드포인트를 호출했을 때 반환되는 응답을 보여줍니다. 여기서 확인할 수 있듯이, 반환된 데이터에는 Public Key 세트 URI가 포함되어 있습니다.

리소스 서버가 JWT를 검증할 수 있도록 이 Public Key 세트 URI를 구성해야 합니다.

 

Listing 15.3 Response of well-known OpenID configuration, containing the key set URI

{
	"issuer": "http://localhost:8080",
	"authorization_endpoint": "http://localhost:8080/oauth2/authorize",
	"device_authorization_endpoint": "http://localhost:8080/oauth2/device_authorization",
	"token_endpoint": "http://localhost:8080/oauth2/token",
    
	…
	"jwks_uri": "http://localhost:8080/oauth2/jwks",   <1>
	…
    
}

<1> key set 엔드포인트는 OAuth 서버 측에 구성된 비대칭 키 쌍의 public 부분을 제공합니다. OAuth 서버는 private key를 사용해 토큰에 서명합니다. 리소스 서버는 public key를 사용해 토큰을 검증할 수 있습니다.

 

public key 세트 URI를 구성하려면 먼저 프로젝트의 application.properties 파일에 선언합니다. 그런 다음 구성 클래스에서 이 값을 필드에 주입한 후, 이를 사용해 리소스 서버 인증을 구성할 수 있습니다:

keySetURI=http://localhost:8080/oauth2/jwks

 

listing 15.4는 구성 클래스가 public key 세트 URI 값을 속성에 주입하는 모습을 보여줍니다. 또한 구성 클래스는 SecurityFilterChain 타입의 빈(bean)을 정의합니다. 애플리케이션은 이 SecurityFilterChain 빈을 사용해 인증을 구성하게 되며, 이는 책의 이전 장에서 했던 것과 유사합니다.

Listing 15.4 Injecting the property value in the configuration class

@Configuration
public class ProjectConfig {
	// Injects the key set URI value in an attribute of the configuration file. 
    // You’ll need it for the filter chain configuration.
	@Value("${keySetURI}")
	private String keySetUri;
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http)
					throws Exception {
		return http.build();
	}
}

 

인증을 구성하기 위해 HttpSecurity 객체의 oauth2ResourceServer() 메서드를 사용할 것입니다. 이 메서드는 httpBasic() 및 formLogin()과 유사합니다.

httpBasic() 및 formLogin()과 마찬가지로, 인증을 구성하기 위해 Customizer 인터페이스의 구현을 제공해야 합니다. listing 15.5에서 볼 수 있듯이, jwt() 메서드를 Customizer 객체와 함께 사용하여 JWT 인증을 구성했습니다. 그런 다음 jwt() 메서드에서 jwkSetUri() 메서드를 사용해 Public Key 세트 URI를 설정하는 Customizer를 적용했습니다.

Listing 15.5 Configuring the authentication with JWTs

@Configuration
public class ProjectConfig {
	
    @Value("${keySetURI}")
	private String keySetUri;
	
    @Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http)
		throws Exception {
		
        // Configuring the app as an OAuth 2 resource server
        http.oauth2ResourceServer(
        	// Configuring the resource server to use JWTs for authentication
			c -> c.jwt(
            	// Configuring the public key set URL 
                // that the resource server will use to validate the tokens
				j -> j.jwkSetUri(keySetUri)
			)
		);
		
        return http.build();
	}
}

 

엔드포인트가 인증을 요구하도록 설정해야 한다는 점을 기억하세요. 디폴트로 엔드포인트는 보호되지 않으므로 인증을 테스트하려면 먼저 /demo 엔드포인트가 인증을 요구하도록 설정해야 합니다.

다음 코드 단편은 애플리케이션의 권한 부여 규칙을 구성하는 내용을 보여줍니다. 이 예제에서는 모든 엔드포인트가 인증을 요구하도록 설정할 수 있습니다:

http.authorizeHttpRequests(
	c -> c.anyRequest().authenticated()
);

 

다음 단편 조각은 구성 클래스의 전체 내용을 보여줍니다.

Listing 15.6 The full configuration class

@Configuration
public class ProjectConfig {
	@Value("${keySetURI}")
	private String keySetUri;

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http)
		throws Exception {
		http.oauth2ResourceServer(
			c -> c.jwt(
				j -> j.jwkSetUri(keySetUri)
			)
		);

		http.authorizeHttpRequests(
			c -> c.anyRequest().authenticated()
		);
		return http.build();
	}
}

 

이제 방금 생성한 리소스 서버 애플리케이션을 시작해야 합니다. 또한 OAuth 서버가 여전히 실행 중인지 확인하세요. 액세스 토큰을 생성하려면 14장에서 배운 기술을 사용해야 합니다.

권한 코드 승인 타입(authorization code grant type)의 단계를 다시 상기해봅시다. 하지만 액세스 토큰을 얻는 방법은 다른 승인 타입을 사용해도 상관없습니다. 리소스 서버 입장에서는 어떻게 토큰을 획득했는지가 중요하지 않으며, 액세스 토큰만 있으면 됩니다.

권한 코드 승인 타입의 단계는 다음과 같습니다 (그림 15.3):

  1. 사용자를 OAuth 서버의 /authorize 엔드포인트로 리디렉션합니다.
  2. 사용자의 자격 증명을 사용해 인증합니다. 이후 OAuth 서버는 리디렉트 URI로 리디렉션하며 권한 코드(authorization code)를 제공합니다.
  3. 리디렉트 후 제공된 권한 코드를 사용해 /token 엔드포인트에 요청하여 새로운 액세스 토큰을 발급받습니다.

Figure 15.3 권한 코드 승인 타입 : 클라이언트는 사용자를 OAuth 서버의 로그인 페이지 로 리디렉션합니다. 사용자가 인증에 성공하면, OAuth 서버는 권한 코드(authorization code)를 제공하며 클라이언트를 리디렉션합니다. 클라이언트는 이 권한 코드를 사용해 액세스 토큰 을 발급받습니다.

 

 

※ Access Tocken 획득 과정 생략...14장 참고

 

Figure 15.4 J.R.R. 톨킨의 소설 《반지의 제왕》에 비유: 액세스 토큰은 매우 소중한 자원입니다. 이를 소지한 사람은 여러 리소스에 접근할 수 있습니다.

 

다음은 OAuth 2 서버로부터 받은 액세스 토큰을 사용해 /demo 엔드포인트로 요청을 보내는 Postman를 보여줍니다:

 

 

15.2 Using customized JWTs

시스템의 요구 사항은 서로 다르며, 이는 인증권한 부여와 같은 측면에서도 마찬가지입니다. 종종 OAuth 서버와 리소스 서버 사이에서 커스텀 값액세스 토큰을 통해 전달해야 하는 경우가 있습니다. 리소스 서버는 이러한 값을 사용해 다양한 권한 부여 규칙을 적용할 수 있습니다.

이 섹션에서는 OAuth 서버와 리소스 서버가 커스텀 클레임을 사용하는 예제를 구현해 보겠습니다.

  • OAuth 서버는 JWT에 priority라는 클레임을 추가합니다.
  • resource 서버는 이 priority 클레임을 읽어 Security Context의 인증 인스턴스에 값을 추가합니다.
  • 이후 resource 서버는 권한 부여 규칙을 구현할 때 이 값을 사용할 수 있습니다.

따라갈 단계

  1. access token에 사용자 지정 클레임을 추가하도록 OAuth 서버를 변경합니다
  2. 사용자 지정 클레임을 읽고 Security Context에 저장하도록 resource 서버를 변경합니다.
  3. 사용자 지정 클레임을 사용하는 권한 부여 규칙을 구현합니다

먼저 해야 할 일은 액세스 토큰 본문에 커스텀 값을 추가하는 것입니다. 이를 위해 OAuth 서버SecurityConfig 클래스에서 OAuth2TokenCustomizer 타입의 빈(bean)을 추가합니다.

다음 코드 단편은 이러한 빈의 정의를 보여줍니다. 예제를 단순화하고 집중할 수 있도록 priority라는 필드에 더미 값을 추가했습니다. 실제 애플리케이션에서는 이러한 커스텀 필드가 목적을 가지며, 그 값을 설정하기 위한 로직을 작성해야 할 수도 있습니다.

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
	return context -> {
		JwtClaimsSet.Builder claims = context.getClaims();
		claims.claim("priority", "HIGH");
	};
}

 

이 최소한의 변경을 통해 이제 액세스 토큰에는 사용자 지정 필드인 priority가 포함됩니다.

다음 listing은 Base64로 인코딩된 JWT 액세스 토큰의 예시를 보여주며, listing 15.7은 이를 디코딩한 본문을 보여줍니다. 여기서 priority 필드를 확인할 수 있습니다.

eyJraWQiOiI5ZTBjOTQ5Ny0zYmMyLTQ4Y2YtODU5MC04N2JmZjE2ZjczOTAiLCJhbGciOiJSUzI
1NiJ9.eyJzdWIiOiJiaWxsIiwiYXVkIjoiY2xpZW50IiwibmJmIjoxNjg3MjYzMzI5LCJzY29wZ
SI6WyJvcGVuaWQiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiZXhwIjoxNjg3MjYz
NjI5LCJwcmlvcml0eSI6IkhJR0giLCJpYXQiOjE2ODcyNjMzMjl9.HrQECSO17tZD8HKXP0U7gm
dmea01vPgVypvcf3oR3uawiMdI_joQBsLY0zNWBIgktKn2w9-
rvgtjD2xmhWZgSxRsDW_GZofqOzV9T-
5llMuZlakF7SQLyI67UJZKuPTJK8hBd1OhnurGo7ikPfDWhaqyychKu_uI7SdFrQQVgVqbrmHii
syoURIrI9EwOhB036M7UPJnIWtOWc34fAoFHxqhPuGIVesHHX5qm6wx-
8_Orjz96eOujVSEuUGRNVtz35_SRjhozcLzgIo3Rt9lUfLI7HSzulfXTCpxtxja-
1E_l_dsk4VHSvLYJUZjlERp5kVJqSO_keaJt8JbDQ0new

 

listing 15.7은 앞서 제시된 액세스 토큰의 디코딩된 본문(body)을 보여줍니다.

jwt.io와 같은 온라인 도구를 사용하면 JWT를 쉽게 디코딩된 형태로 확인할 수 있습니다. 또는 다른 Base64 디코더를 사용해 액세스 토큰의 헤더본문(body)을 개별적으로 Base64로 디코딩할 수도 있습니다.

다음 listing은 OAuth2 서버에 적용한 변경 사항이 정상적으로 작동하는지를 보여줍니다.

Listing 15.7 The Base64-decoded body of the customized JWT access token

{
	"sub": "bill",
	"aud": "client",
	"nbf": 1687263329,
	"scope": [
		"openid"
	],
	"iss": "http://localhost:8080",
	"exp": 1687263629,
	"priority": "HIGH",
	"iat": 1687263329
}

 

두 번째 단계로, 리소스 서버에 변경 사항을 적용합니다. 이전 15.1절에서 사용했던 예제를 계속 활용할 수 있지만, 학습을 더 쉽게 하기 위해 이 예제를 위한 별도의 프로젝트를 만들었습니다. 이 섹션에서 논의하는 구현은 ch15-ex2 프로젝트에서 확인할 수 있습니다.

리소스 서버가 액세스 토큰의 사용자 지정 클레임을 이해하도록 변경하기 위한 단계는 다음과 같습니다:

1. 커스텀 인증 객체를 생성합니다. 이 객체는 커스텀 데이터를 포함하는 새로운 형태를 정의합니다.
2. JWT 인증 변환기 객체를 생성합니다. 이 객체는 JWT를 커스텀 인증 객체로 변환하는 로직을 정의합니다.
3. 2단계에서 생성한 JWT 인증 변환기를 인증 메커니즘에서 사용하도록 구성합니다.
4. /demo 엔드포인트를 변경하여 Security Context에서 인증 객체를 반환하도록 합니다.
5. 엔드포인트를 테스트하고 인증 객체에 사용자 지정 priority 필드가 포함되어 있는지 확인합니다.

 

listing 15.8은 인증 객체의 정의를 보여줍니다. 인증 객체AbstractAuthenticationToken 클래스를 직간접적으로 상속하는 임의의 클래스여야 합니다. JWT를 사용하기 때문에 JwtAuthenticationToken을 확장하는 것이 더 편리합니다. 이렇게 하면 JWT 액세스 토큰에 맞게 설계된 기본 인증 객체 구조를 바로 확장할 수 있습니다.

listing 15.8에서 추가된 priority 필드를 확인해 보세요. 이 필드는 액세스 토큰 본문에 있는 사용자 지정 클레임의 값을 저장합니다. 이와 유사한 방식으로, 애플리케이션에서 권한 부여에 필요한 다른 커스텀 세부 정보를 추가할 수 있습니다.

Security Context의 인증 객체에 이러한 세부 정보를 직접 추가하면 엔드포인트 레벨(7장과 8장)이나 메서드 레벨(11장과 12장)에서 이를 적용할 때 설정을 쉽게 작성할 수 있습니다.

 

Listing 15.8 Defining a custom authentication object

// Customizing the authentication object by extending the JwtAuthenticationToken class
public class CustomAuthentication extends JwtAuthenticationToken {

	// Adding the custom field “priority”
    private final String priority;
    
	public CustomAuthentication(Jwt jwt,
						Collection<? extends GrantedAuthority> authorities,
						String priority) {
		super(jwt, authorities);
		this.priority = priority;
	}
    
	public String getPriority() {
		return priority;
	}
}

 

인증 객체의 커스텀 형태를 만들었으니, 이제 JWT를 이 커스텀 객체로 변환하는 방법을 애플리케이션에 지시해야 합니다. 이를 위해 특정 Converter를 구성할 수 있으며, 이는 listing 15.9에 나와 있습니다.

여기서 사용된 두 개의 제네릭 타입을 확인해 보세요:

  • Jwt: 변환기의 입력 타입입니다.
  • CustomAuthentication: 변환기의 출력 타입입니다.

즉, 이 ConverterJwt 객체(Spring Security에서 JWT 액세스 토큰을 읽는 표준 계약)를 우리가 이전 listing 15.8에서 구현한 커스텀 타입으로 변환합니다. (참고: 그림 15.5를 참조하세요.)

Figure 15.5 커스텀 변환기(Converter)는 액세스 토큰의 정보를 가져와 커스텀 인증 객체 형태에 넣는 로직을 구현합니다.

 

Listing 15.9 Converting the access token to an authentication object

@Component
public class JwtAuthenticationConverter implements Converter<Jwt, CustomAuthentication> {
	
    @Override
	public CustomAuthentication convert(Jwt source) {
		List<GrantedAuthority> authorities =
				List.of(() -> "read");
                
        // Getting the priority values from the token’s custom claim 
		String priority =
				String.valueOf(source.getClaims().get("priority"));
                
        // Setting the priority value in the authentication object
		return new CustomAuthentication(source,
				authorities,
				priority);
	}
}

 

Listing 15.9에서 더미 권한(dummy authority)을 정의한 것도 확인할 수 있습니다. 실제 시나리오에서는 권한을 액세스 토큰에서 가져오거나(권한이 OAuth 서버에서 관리되는 경우), 데이터베이스나 다른 서드파티 시스템에서 가져올 수 있습니다(비즈니스 관점에서 관리되는 경우).

여기서는 예제를 단순화하기 위해 모든 요청에 대해 "read"라는 더미 권한을 추가했습니다. 그러나 이 부분이 권한(authorities)을 처리하는 곳임을 기억하는 것이 중요합니다. 권한은 시큐리티 컨텍스트의 인증 객체에 포함되어야 하며, 대부분의 경우 권한 부여 규칙에 필요한 필수 세부 정보입니다.

다음 listing은 커스텀 변환기를 구성하는 방법을 보여줍니다. 이 경우 의존성 주입을 사용해 Spring 컨텍스트에서 변환기 빈(bean)을 가져옵니다. 그런 다음 JWT 인증 설정에서 jwtAuthenticationConverter() 메서드를 사용했습니다.

Listing 15.10 Configuring the custom authentication converter

@Configuration
public class ProjectConfig {

	// omitted code
	// Injecting the converter object in a class field
	private final JwtAuthenticationConverter converter;
	
	// omitted constructor
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http)
			throws Exception {
			
		http.oauth2ResourceServer(
			c -> c.jwt(
				j -> j.jwkSetUri(keySetUri)
						// Configuring the converter object within the authentication mechanism
						.jwtAuthenticationConverter(converter)
			)
		);
		
		http.authorizeHttpRequests(
			c -> c.anyRequest().authenticated()
		);
		
		return http.build();
	}
}

 

액세스 토큰의 커스텀 클레임을 사용하기 위해 필요한 구성은 여기까지입니다. 이제 구현이 정상적으로 작동하는지 테스트해 봅시다.

다음 코드 단편은 /demo 엔드포인트에 적용한 변경 사항을 보여줍니다. /demo 엔드포인트가 시큐리티 컨텍스트에서 Authentication 인스턴스를 반환하도록 만들었습니다.

Spring은 Authentication 타입의 파라미터를 자동으로 주입하는 방법을 알고 있기 때문에, 이 파라미터를 추가하고 엔드포인트의 액션 메서드가 이를 그대로 리턴하도록 만들기만 하면 됩니다:

@GetMapping("/demo")
public Authentication demo(Authentication a) {
	return a;
}

 

모든 것이 예상대로 작동하면 /demo 엔드포인트로 요청을 보낼 때, 다음 listing에 제시된 것과 유사한 본문을 포함한 응답을 받게 됩니다.

여기서 커스텀 priority 속성이 인증 객체에 올바르게 나타나며, 그 값이 HIGH인 것을 확인할 수 있습니다.

Listing 15.11 The /demo endpoint response contains the priority field

 {
  "authorities": [
    {
      "authority": "read"
    }
  ],
  "details": {
     "remoteAddress": "0:0:0:0:0:0:0:1",
     "sessionId": null
  },
  "authenticated": true,
    …
  "name": "bill",
  "priority": "HIGH",  <-- The custom claim value appears in the authentication instance.
 }

 

15.3 Configuring token validation through introspection

이 섹션에서는 인트로스펙션(introspection)을 사용해 액세스 토큰을 검증하는 방법에 대해 논의합니다. 애플리케이션이 불투명 토큰(opaque tokens)을 사용하거나, OAuth 서버에서 토큰을 무효화할 수 있는 시스템이 필요하다면, 인트로스펙션이 토큰 검증을 위해 반드시 사용해야 하는 프로세스입니다.

그림 15.6인트로스펙션 프로세스를 상기시켜주며, 이에 대한 자세한 내용은 14.4절에서 다루었습니다.

Figure 15.6 토큰 인트로스펙션 : 리소스 서버가 액세스 토큰의 서명 기반 검증 에 의존할 수 없는 상황(예: 토큰 무효화가 필요한 경우)이나, 토큰에 자세한 정보가 포함되지 않은 경우(불투명 토큰과 같은 경우), 리소스 서버는 OAuth 서버 에 문의해야 합니다. 이를 통해 토큰의 유효성 을 확인하고 추가 정보를 얻을 수 있습니다.

 

인트로스펙션을 사용하는 리소스 서버를 구현하기 위해 다음 단계를 따릅니다:

  1. OAuth 서버가 리소스 서버를 클라이언트로 인식하도록 설정합니다. 리소스 서버는 OAuth 서버 측에 등록된 클라이언트 자격 증명을 필요로 합니다.
  2. 리소스 서버 측에서 인트로스펙션을 사용하도록 인증을 구성합니다.
  3. OAuth 서버에서 액세스 토큰을 발급받습니다.
  4. /demo 엔드포인트를 사용해 3단계에서 얻은 액세스 토큰으로 설정이 예상대로 작동하는지 확인합니다.

다음 코드 단편은 OAuth 서버 측에 등록할 클라이언트 인스턴스를 생성하는 예시를 보여줍니다. 이 클라이언트는 우리의 리소스 서버를 나타냅니다. 그림 15.6에서 볼 수 있듯이, 리소스 서버는 인트로스펙션을 위해 OAuth 서버로 요청을 보내므로, 리소스 서버는 OAuth 서버의 클라이언트가 됩니다.

인트로스펙션 요청을 보내기 위해, 리소스 서버는 다른 클라이언트와 마찬가지로 인증을 위한 클라이언트 자격 증명이 필요합니다.

이 예제를 위해, 14장에서 불투명 토큰(opaque tokens)을 논의하며 생성한 ch14-ex4 프로젝트를 변경할 것입니다.

RegisteredClient resourceServer =   
   RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("resource_server")
            .clientSecret("resource_server_secret")
            .clientAuthenticationMethod(
               ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(
               AuthorizationGrantType.CLIENT_CREDENTIALS)
            .build();

 

비밀번호와 설정 데이터는 절대로 이전 listing에서처럼 하드코딩해서는 안 됩니다. 저는 우리가 논의하는 주제에 집중할 수 있도록 예제를 최대한 단순화했습니다. 실제 애플리케이션에서는 구성을 구현 코드 외부의 파일에 저장하고, 비밀 정보(예: 자격 증명)는 안전하게 보관해야 합니다.

다음 단편 조각은 RegisteredClientRepository 컴포넌트에 클라이언트리소스 서버의 클라이언트 세부 정보를 추가하는 방법을 보여줍니다.

Listing 15.12 The RegisteredClientRepository definition

@Bean
 public RegisteredClientRepository registeredClientRepository() {
 //  Defining a client details instance for the client app
  RegisteredClient registeredClient =      
    RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId("client")
      .clientSecret("secret")
      .clientAuthenticationMethod(
         ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
      .tokenSettings(TokenSettings.builder()
          .accessTokenFormat(OAuth2TokenFormat.REFERENCE)
          .accessTokenTimeToLive(Duration.ofHours(12))
          .build())
      .scope("CUSTOM")
      .build();
      
  // Defining a client details instance for the resource server 
  // (which also becomes a client when calling the introspection endpoint)    
  RegisteredClient resourceServer =     
    RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId("resource_server")
      .clientSecret("resource_server_secret")
      .clientAuthenticationMethod(
         ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .authorizationGrantType(
         AuthorizationGrantType.CLIENT_CREDENTIALS)
      .build();
      
    //  Adding both client details instances in the authorization server’s repository  
    return new InMemoryRegisteredClientRepository(
                    registeredClient,       
                    resourceServer);
  }

 

listing 15.12에서 변경 사항을 적용하면, 이제 리소스 서버가 OAuth 서버가 노출하는 인트로스펙션 엔드포인트를 호출할 때 사용할 자격 증명을 갖추게 됩니다. 이로써 리소스 서버 구현을 시작할 수 있습니다. 이 예제는 ch15-ex3 프로젝트에서 확인할 수 있습니다.

listing  15.13은 인트로스펙션을 위해 properties 파일에서 구성한 세 가지 필수 값을 보여줍니다:

  1. 인트로스펙션 URI: OAuth 서버가 노출하며, 리소스 서버가 이 URI를 통해 토큰을 검증할 수 있습니다.
  2. 리소스 서버 Client ID: 리소스 서버가 인트로스펙션 엔드포인트를 호출할 때 자신의 ID를 식별하는 데 사용됩니다.
  3. 리소스 서버 Client Secret: 리소스 서버가 클라이언트 ID와 함께 요청을 보낼 때 인증을 위해 사용됩니다.

이와 함께 서버 포트9090으로 변경했습니다. 이는 OAuth 서버의 포트(기본값 8080)와 다르게 설정하여 두 애플리케이션이 동시에 실행될 수 있도록 했습니다.

Listing 15.13 The resource server application.properties file

# Changing the resource server’s port to allow both the resource server 
# and authorization server to run simultaneously
server.port=9090    
introspectionUri=http://localhost:8080/oauth2/introspect

# Configuring the resource server client ID as a property
resourceserver.clientID=resource_server    

# Configuring the resource server client secret as a property
resourceserver.secret=resource_server_secret

 

그런 다음 properties 파일의 값을 구성 클래스의 필드에 주입하고, 이를 사용해 인증 설정을 구성할 수 있습니다.

다음 listing은 구성 클래스가 properties 파일에서 값을 필드에 주입하는 모습을 보여줍니다.

Listing 15.14 Injecting the values in fields of the configuration class

//  Injecting the introspection URI, 
// introspection client ID, and introspection secret 
// from the properties file in fields of the configuration class
@Configuration
public class ProjectConfig {
	@Value("${introspectionUri}")    
	private String introspectionUri;
	@Value("${resourceserver.clientID}")     
	private String resourceServerClientID;
	@Value("${resourceserver.secret}")    
	private String resourceServerSecret;
}

 

인트로스펙션 URI와 자격 증명을 사용해 인증을 구성합니다. 이 과정은 JWT 액세스 토큰을 구성했던 방법과 유사하게 HttpSecurity 객체의 oauth2ResourceServer() 메서드를 사용합니다. 그러나 이번에는 oauth2ResourceServer() 커스터마이저 객체에서 opaqueToken() 메서드를 호출합니다.

opaqueToken() 메서드를 사용해 인트로스펙션 URI자격 증명을 구성합니다. 다음 listing은 이 구성을 보여줍니다.

Listing 15.15 Configuring the resource server authentication for opaque tokens

@Configuration
public class ProjectConfig {
  @Value("${introspectionUri}")
  private String introspectionUri;
  @Value("${resourceserver.clientID}")
  private String resourceServerClientID;
  @Value("${resourceserver.secret}")
  private String resourceServerSecret;
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http)
    throws Exception {
 
     http.oauth2ResourceServer(
        // Configuring resource server authentication for opaque tokens
        c -> c.opaqueToken(   
          // Configuring the introspection URI the resource server should 
          // use to validate and get details about tokens
          o -> o.introspectionUri(introspectionUri)  
                // Configuring the credentials the resource server must 
                // use to authenticate when calling the authorization 
                // server’s introspection URI
                .introspectionClientCredentials(     
                    resourceServerClientID, 
                    resourceServerSecret)
            )
     );
     return http.build();
  }
 }

 

권한 부여 구성도 추가해야 한다는 점을 기억하세요. 다음 코드 단편은 7장과 8장에서 학습한 방식으로 모든 엔드포인트가 요청 시 인증을 요구하도록 설정하는 표준 방법을 보여줍니다:

http.authorizeHttpRequests(
  c -> c.anyRequest().authenticated()
 );

 

다음 listing은 구성 클래스의 전체 내용을 보여줍니다.

Listing 15.16 Full contents of the configuration class

@Configuration
public class ProjectConfig {

  @Value("${introspectionUri}")
  private String introspectionUri;
  @Value("${resourceserver.clientID}")
  private String resourceServerClientID;
  @Value("${resourceserver.secret}")
  private String resourceServerSecret;
  
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) 
    throws Exception {
     http.oauth2ResourceServer(
        c -> c.opaqueToken(
          o -> o.introspectionUri(introspectionUri)    
                .introspectionClientCredentials(
                    resourceServerClientID, 
                    resourceServerSecret)
            )
     );

     // Adding the endpoint authorization configuration. 
     // Requests for any endpoint require authentication.
     http.authorizeHttpRequests(     
        c -> c.anyRequest().authenticated()
     );
     return http.build();
  }
 }

 

다음 코드 단편과 같은 간단한 /demo 엔드포인트만으로도 인증이 올바르게 작동하는지 테스트하기에 충분합니다.

@RestController
public class DemoController {
  @GetMapping("/demo")
  public String demo() {
    return "Demo";
  }
}

 

이제 OAuth 서버리소스 서버 두 애플리케이션을 동시에 실행할 수 있습니다. 다음 코드 단편은 /token 엔드포인트로 요청을 보내기 위해 사용할 수 있는 cURL 명령어를 보여줍니다.

이 예제를 단순화하기 위해 클라이언트 자격 증명 방식(client credentials grant type)을 사용했지만, 14장에서 배운 다른 승인 타입을 사용해도 상관없습니다. 중요한 점은 리소스 서버 구성은 액세스 토큰을 획득하는 방식과 무관하게 동일하다는 것입니다:

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

 

요청이 성공하면 응답으로 액세스 토큰을 받게 됩니다. 응답 본문은 다음 단편 조각과 비슷한 형태를 가집니다. 토큰 값은 페이지에 맞추기 위해 일부를 생략했습니다:

{
 
    "access_token": "2zLyYA8b6Q54-…",
    "token_type": "Bearer",
    "expires_in": 43199
    
}

 

JWT 액세스 토큰과 마찬가지로, 보호된 엔드포인트로 요청을 보낼 때는 "Authorization" 헤더에 토큰을 추가해야 합니다. 액세스 토큰 값 앞에는 "Bearer" 문자열이 접두사로 붙어야 합니다.

다음 단편 조각은 /demo 엔드포인트로 요청을 보내기 위해 사용할 수 있는 cURL 명령어를 보여줍니다. 모든 것이 올바르게 작동하면 응답 본문"Demo" 문자열이 반환되며, 200 OK 상태 코드가 나타납니다:

 curl 'http://localhost:9090/demo' \--header 'Authorization: Bearer 2zLyYA8b6Q54-…'

 

 

15.4 Implementing multitenant systems

실제 애플리케이션에서는 항상 모든 것이 완벽하지는 않습니다. 때로는 서드파티와 통합할 때 비표준적인 사례에 맞춰 구현을 조정해야 하는 상황에 처할 수 있습니다. 또한 여러 OAuth 서버에 의존하는 백엔드를 구현해야 할 때도 있습니다(예: 멀티테넌트 시스템). 이런 경우 애플리케이션의 설정은 어떻게 구현해야 할까요?

다행히도 Spring Security는 어떤 시나리오든 구현할 수 있는 유연성을 제공합니다. 이 섹션에서는 멀티테넌트 시스템이나 표준을 따르지 않는 애플리케이션과 상호작용하는 경우와 같은 복잡한 사례를 위한 리소스 서버 설정 방법을 논의합니다.

그림 15.7을 통해 이 책의 첫 번째와 두 번째 부분에서 자세히 다룬 Spring Security의 인증 설계를 다시 살펴봅시다.

  • 필터가 HTTP 요청을 가로챕니다.
  • 인증 책임은 AuthenticationManager에 위임됩니다.
  • AuthenticationManagerAuthenticationProvider를 사용하여 인증 로직을 구현합니다.

Figure 15.7 Authentication 클래스 설계 : 인증 절차에서 필터는 요청을 가로채고 이를 AuthenticationManager 컴포넌트에 전달합니다. 이 매니저는 AuthenticationProvider 를 사용해 필요한 인증 로직을 실행합니다. 인증이 성공하면 애플리케이션은 인증된 주체의 세부 정보를 보안 컨텍스트(Security Context)에 기록합니다.

 

이 설계를 기억하는 것이 중요한 이유는 무엇일까요? 리소스 서버의 경우, 다른 인증 접근 방식과 마찬가지로 인증 로직을 커스터마이징하려면 AuthenticationProvider를 변경해야 하기 때문입니다.

리소스 서버의 경우, Spring SecurityAuthenticationManagerResolver라는 컴포넌트를 구성에 추가할 수 있도록 합니다 (그림 15.8). 이 컴포넌트를 사용하면 애플리케이션 실행 시 어떤 AuthenticationManager를 호출할지 결정할 수 있습니다.

이 방식을 통해 커스텀 AuthenticationProvider를 사용하는 커스텀 AuthenticationManager에 인증을 위임할 수 있습니다.

Figure 15.8 AuthenticationManagerResolver 를 구현하면 애플리케이션에 어떤 AuthenticationManager 에게 인증 책임을 위임할지 지시할 수 있습니다.

 

모든 OAuth 서버JWT를 사용하는 환경에서 애플리케이션이 여러 OAuth 서버를 사용할 경우, Spring Security기본 제공되는 AuthenticationManagerResolver 구현을 제공합니다 (그림 15.9).

이런 경우, Spring Security가 제공하는 JwtIssuerAuthenticationManagerResolver 커스텀 구현을 구성에 추가하기만 하면 됩니다.

 

Figure 15.9 Your system might need to use multiple authorization servers to authenticate the users and clients.

 

listing 15.17은 인증을 구성할 때 authenticationManagerResolver() 메서드를 사용하는 방법을 보여줍니다.

이 예제에서 확인할 수 있듯이, 저는 JwtIssuerAuthenticationManagerResolver 클래스의 인스턴스를 생성한 후, 모든 OAuth 서버의 발급자 주소(issuer addresses)를 제공했습니다.

이 예제는 ch15-ex4 프로젝트에서 구현된 내용을 확인할 수 있습니다.

참고 URL이나 기타 비슷한 구성 가능한 세부 정보를 코드에 직접 작성하지 마세요. 이 접근 방식은 예제 코드에서만 사용되며, 학습의 핵심에 집중할 수 있도록 단순화한 것입니다.
구성이 가능한 항목들은 항상 설정 파일이나 환경 변수에 작성해야 합니다.

Listing 15.17 Working with two authorization servers that use JWT access tokens

@Configuration
 public class ProjectConfig {
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) 
    throws Exception {
    http.oauth2ResourceServer(
      j -> j.authenticationManagerResolver(
               authenticationManagerResolver())
    );
    http.authorizeHttpRequests(
      c -> c.anyRequest().authenticated()
    );
 
    return http.build();
  }
  @Bean
  public AuthenticationManagerResolver<HttpServletRequest> 
     authenticationManagerResolver() {
    var a = new JwtIssuerAuthenticationManagerResolver(
        "http://localhost:7070", 
        "http://localhost:8080");
    return a;
  }
 }

 

그림 15.10에서 제시된 설정과 같은 구성을 사용하면, 리소스 서버는 포트 70708080에서 실행 중인 두 개의 OAuth 서버와 함께 작동합니다.

그러나 경우에 따라 상황이 더 복잡해질 수 있습니다. Spring Security가 모든 커스터마이징을 제공할 수는 없습니다. 이처럼 리소스 서버의 기능을 더 세밀하게 커스터마이징해야 하는 경우, 커스텀 AuthenticationManagerResolver를 구현해야 합니다.

다음과 같은 시나리오를 가정해 봅시다:
리소스 서버가 두 개의 서로 다른 OAuth 서버와 함께 작동해야 하며, 하나는 JWT 토큰을 사용하고 다른 하나는 불투명(opaque) 토큰을 사용하는 경우입니다. 리소스 서버는 "type" 파라미터의 값을 기준으로 요청을 구분합니다.

  • "type" 파라미터의 값이 "jwt"인 경우, 리소스 서버는 JWT 액세스 토큰을 사용하는 OAuth 서버로 요청을 인증합니다.
  • 그렇지 않으면 불투명(opaque) 액세스 토큰을 사용하는 OAuth 서버를 사용합니다.

Figure 15.10 두 개의 서로 다른 OAuth 서버 를 사용하며, 각각 다른 타입의 토큰을 처리합니다. 클라이언트가 HTTP 요청 헤더에 설정한 특정 값을 기반으로 리소스 서버는 어떤 OAuth 서버를 사용할지 결정하고 액세스 토큰을 검증합니다.

 

listing 15.18은 이 시나리오를 구현한 예제입니다. 리소스 서버는 HTTP 요청의 "type" 헤더 값에 따라 다른 OAuth 서버를 사용합니다. 이를 위해 리소스 서버는 헤더 값에 따라 다른 AuthenticationManager를 사용합니다.

 

Listing 15.18 Using both JWT and opaque tokens

@Configuration
public class ProjectConfig {
  // Omitted code
  @Bean
  public AuthenticationManagerResolver<HttpServletRequest> 
                  authenticationManagerResolver(
                             JwtDecoder jwtDecoder, 
                             OpaqueTokenIntrospector opaqueTokenIntrospector
  ) {
  
    // Defining an authentication manager 
    // for the authorization server managing 
    // JWT access tokens
  	AuthenticationManager jwtAuth = new ProviderManager(    
    	new JwtAuthenticationProvider(jwtDecoder)    
    );
    
    // Defining another authentication manager 
    // for the authorization server managing 
    // opaque tokens
    AuthenticationManager opaqueAuth = new ProviderManager(     
      new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector)
    );
    
    // Defining the custom authentication manager resolver logic 
    // to pick an authentication manager  
    // based on the “type” header of the HTTP request
    return (request) -> {     
      if ("jwt".equals(request.getHeader("type"))) {
         return jwtAuth;
      } else {
         return opaqueAuth;
      }
    };
  }
  
  @Bean
  public JwtDecoder jwt'Decoder() {
    // Configuring the public key set URI 
    // for the authentication manager working 
    // with the authorization server that manages JWT access tokens
    return NimbusJwtDecoder    
            .withJwkSetUri("http://localhost:7070/oauth2/jwks")
            .build();
  }
  
  @Bean
  // Configuring the introspection URI and credentials 
  // for the authentication manager working 
  // with the authorization server that manages opaque tokens
  public OpaqueTokenIntrospector opaqueTokenIntrospector() {    
    return new SpringOpaqueTokenIntrospector(
       "http://localhost:6060/oauth2/introspect",
       "client", "secret");
  }
}

 

다음 listing은 authenticationManagerResolver() 메서드의 커스터마이저(customizer) 파라미터를 사용하여 커스텀 AuthorizationManagerResolver를 구성하는 나머지 설정을 보여줍니다.

Listing 15.19 Configuring the AuthenticationManagerResolver

@Configuration
public class ProjectConfig {
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) 
    throws Exception {
    http.oauth2ResourceServer(
      //  Configuring the custom authentication manager resolver
      j -> j.authenticationManagerResolver(     
                authenticationManagerResolver(
                  jwtDecoder(), 
                  opaqueTokenIntrospector()
                ))
    );
    http.authorizeHttpRequests(
      c -> c.anyRequest().authenticated()
    );
    return http.build();
  }
  // Omitted code
}

 

이 예제에서도 Spring Security가 제공하는 authentication provider 구현체를 사용했습니다:

  • JwtAuthenticationProvider: 표준 OAuth 서버와 함께 JWT 액세스 토큰을 사용하는 인증 로직을 구현합니다.
  • OpaqueTokenAuthenticationProvider: 불투명(opaque) 토큰과 함께 작동하는 인증 로직을 구현합니다.

그러나 실제 애플리케이션에서는 이보다 훨씬 복잡한 상황이 발생할 수 있습니다.

만약 표준을 따르지 않는 시스템과 통합해야 하는 경우처럼 매우 커스터마이징된 요구 사항이 있다면, 커스텀 AuthenticationProvider를 직접 구현할 수도 있습니다.