권한 서버는 사용자를 인증하고, 사용자가 사용하는 애플리케이션(클라이언트)과 백엔드에서 보호된 리소스에 접근하기 위한 인증 증명으로 작동하는 토큰을 발급하는 역할을 한다. 때로는 클라이언트가 사용자를 대신해 이러한 작업을 수행하기도 한다.
Spring 생태계에서는 OAuth 2/OpenID Connect 권한 서버를 구현하기 위한 완전히 커스터마이징 가능한 솔루션을 제공하며, 이 중 Spring Security 권한 서버는 현재 Spring 기반 애플리케이션에서 권한 서버를 구현하는 데 사실상의 표준 방식으로 자리 잡고 있다.
1.1 JSON 웹 토큰을 사용하여 기본 인증 구현
Spring Security 권한 서버 프레임워크를 사용하여 basic OAuth 2 권한 서버를 구현한다.
권한 서버를 올바르게 설정하기 위해 필요한 주요 구성 요소는 다음과 같다.
- 프로토콜 엔드포인트를 위한 구성 필터: 권한 서버 기능 (capabilities)에 특정한 구성을 정의하도록 돕는 필터다. 다양한 커스터마이징을 포함한다.
- 인증 구성 필터: Spring Security로 보호된 웹 애플리케이션과 유사하게, 인증 및 권한 부여 구성을 정의하기 위해 이 필터를 사용한다. CORS 및 CSRF와 같은 기타 보안 메커니즘 구성도 정의한다.
- UserDetailService 관리 구성 요소: Spring Security로 구현된 인증 과정과 마찬가지로, UserDetailsService 빈과 PasswordEncoder를 통해 설정된다.
- 클라이언트 세부 정보 관리: 권한 서버는 RegisteredClientRepository라는 구성 요소를 사용하여 클라이언트 자격 증명 및 기타 세부 정보를 관리한다.
- Key-Pairs (used to sign/서명 and validate tokens) 관리: 투명 토큰을 사용하는 경우, 권한 서버는 private 키를 사용해 토큰을 서명한다. 리소스 서버가 토큰을 검증할 수 있도록 권한 서버는 public 키에 대한 접근도 제공한다. 권한 서버는 key source 구성 요소를 통해 private 키와 public 키 쌍을 관리한다.
- 일반 앱 설정: AuthorizationServerSettings라는 구성 요소를 사용하여 애플리케이션이 노출하는 엔드포인트와 같은 일반적인 커스터마이징을 구성한다.
먼저 프로젝트에 필요한 의존성을 추가해야 한다. 아래의 코드를 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>
구성은 일반적인 스프링 구성 클래스에서 작성한다.
@Configuration
public class SecurityConfig { }
Spring 애플리케이션에서와 마찬가지로, 빈은 여러 구성 클래스에 정의할 수 있으며, 경우에 따라 스테레오타입 애노테이션을 사용해 정의할 수도 있다.
applyDefaultSecurity() 메서드는 필요한 경우 나중에 재정의할 수 있는 최소 구성 세트를 정의하기 위해 사용하는 유틸리티 메서드다. 이 메서드를 호출한 후, Listing에서는 OAuth2AuthorizationServerConfigurer 객체의 oidc() 메서드를 사용해 OpenID Connect 프로토콜을 활성화하는 방법을 보여준다.
또한, 예제 1의 필터는 로그인 요청 시 사용자 리디렉션에 필요한 인증 페이지를 지정한다.
해당 예제에서 권한 코드 그랜트 타입(authorization code grant type)을 활성화하려 하기 때문에 이 구성이 필요하다. Spring 웹 애플리케이션에서 기본 경로는 /login이며, 커스텀 경로를 설정하지 않는 한, 권한 서버 구성에서는 이 경로를 사용하게 된다.
▼ 예제 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();
}
예제 2는 인증 및 권한 부여를 구성한다. 이러한 구성은 일반적인 웹 애플리케이션과 유사하게 동작하며 최소 구성만 설정 한다.
- Form 로그인 인증을 활성화하여 애플리케이션이 사용자에게 간단한 로그인 페이지를 제공하고 인증할 수 있도록 한다.
- 애플리케이션이 모든 엔드포인트에 대해 인증된 사용자만 접근할 수 있도록 지정한다.
여기에는 인증과 권한 부여 외에도 CSRF나 CORS와 같은 특정 보호 메커니즘을 설정하는 구성이 포함될 수 있다. 또한, @Order 애노테이션에 주의해야 한다. 이 애노테이션은 애플리케이션 컨텍스트에서 여러 SecurityFilterChain 인스턴스를 설정할 때 필수적이다. 이는 설정에서 우선 순위를 매겨야 하기 때문이다.
▼ 예제 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를 관리해야 한다.
필요한 것은 UserDetailsService와 PasswordEncoder 구현이다. 예제 3에서는 이 두 구성 요소의 정의를 보여준다. 이 예제에서는 UserDetailsService의 메모리 내 (in-memory) 구현을 사용하여 테스트한다. 대부분의 경우, 다른 웹 애플리케이션과 마찬가지로 이러한 세부 사항은 데이터베이스에 저장된다. 따라서 UserDetailsService 인터페이스를 커스터마이징하여 구현해야 한다.
NoOpPasswordEncoder는 비밀번호를 변환하지 않고 평문 상태로 남겨두며, 접근 권한이 있는 사람이라면 누구든지 확인할 수 있도록 한다. 이는 바람직하지 않으며, 항상 BCrypt와 같은 강력한 해시 함수를 사용하는 비밀번호 인코더를 사용해야 한다.
▼ 예제 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();
}
권한 서버는 클라이언트 세부 정보를 관리하기 위해 RegisteredClientRepository 구성 요소를 사용한다.
RegisteredClientRepository 인터페이스는 UserDetailsService와 유사한 방식으로 동작하며, 클라이언트 세부 정보를 검색하도록 설계되었다.
또한, 프레임워크는 RegisteredClient 객체를 제공하는데, 이 객체는 권한 서버가 인식하는 클라이언트 애플리케이션의 정보를 설명하는 데 사용된다.
RegisteredClient는 클라이언트를 위한 UserDetails와 같고, RegisteredClientRepository는 사용자 세부 정보를 처리하는 UserDetailsService와 유사하게 클라이언트 세부 정보를 다룬다.
예제 4는 메모리 내 RegisteredClientRepository 빈의 정의를 보여준다. 이 메서드는 필요한 세부 정보로 하나의 RegisteredClient 인스턴스를 생성하고, 권한 서버에서 인증 시 사용할 수 있도록 메모리에 저장한다.
▼ 예제 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 토큰을 포함한 모든 그랜트 타입을 사용할 수 있다.
아래의 클라이언트는 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 관리 구성을 해야 한다.
투명 토큰의 경우, 권한 서버는 private 키를 사용해 토큰에 서명하고, 클라이언트가 토큰의 진위 여부를 확인할 수 있도록 public 키를 제공한다.
JWKSource는 Spring Security 권한 서버를 위한 키 관리를 제공하는 객체다.
예제 5는 애플리케이션 컨텍스트에서 JWKSource를 구성하는 방법을 보여준다.
이 예제에서는 키 쌍을 프로그래밍 방식으로 생성하고, 권한 서버가 사용할 수 있는 키 세트에 추가한다.
실제 애플리케이션에서는 키를 안전하게 저장된 위치(예: 환경에 구성된 Vault)에서 읽어온다.
실제 시스템을 완벽히 재현하는 환경을 구성하는 것은 너무 복잡하므로, 권한 서버 구현에 집중할 수 있도록 한다.
그러나 실제 애플리케이션에서는 애플리케이션이 재시작될 때마다 새로운 키를 생성하는 것은 적합하지 않다(우리 예제처럼).
실제 애플리케이션에서 이런 일이 발생하면, 새 배포가 이루어질 때마다 기존에 발급된 토큰이 더 이상 작동하지 않게 된다(기존 키로 유효성을 확인할 수 없기 때문).
따라서, 이 예제에서는 프로그래밍 방식으로 키를 생성하는 것이 적합하며, 이를 통해 권한 서버가 어떻게 작동하는지 시연할 수 있다.
하지만 실제 애플리케이션에서는 키를 안전한 장소에 보관하고, 해당 위치에서 키를 읽어와야 한다.
▼ 예제 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과 같이 객체를 생성하면, 엔드포인트 경로는 기본값을 가지게 되며, 이에 대해서는 이 섹션에서 나중에 분석할 것이다.
▼ 예제 6. Configuring the authorization server generic settings
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
이제 애플리케이션을 시작하고 제대로 작동하는지 테스트할 수 있다.
2. 인증 코드 부여 유형 실행
등록된 client details를 사용하여 권한 코드 플로우를 따라 액세스 토큰을 얻을 수 있을 것으로 예상한다.
- 권한 서버가 노출하는 엔드포인트를 확인한다.
- 권한 엔드포인트를 사용하여 권한 코드를 얻는다.
- 권한 코드를 사용하여 액세스 토큰을 얻는다.
첫 번째 단계는 권한 서버가 노출하는 엔드포인트 경로를 찾는 것이다. 커스텀 경로를 구성하지 않았으므로 디폴트 값을 사용해야 한다. 하지만 디폴트 값은 무엇일까? 다음 코드 단편에 나오는 OpenID 구성 엔드포인트를 호출하여 이러한 세부 정보를 확인할 수 있다. 이 요청은 HTTP GET 메서드를 사용하며, 인증이 필요하지 않다.
http://localhost:8080/.well-known/openid-configuration
OpenID 구성 엔드포인트를 호출하면 다음 예제에 제시된 것과 유사한 응답을 받을 수 있다.
▼ 예제 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"
]
}
우리 예제에는 클라이언트가 없기 때문에, 클라이언트처럼 행동해야 한다. 권한 엔드포인트를 이미 알고 있으므로, 클라이언트가 사용자를 리디렉션하는 것처럼 브라우저 주소창에 이를 입력하여 시뮬레이션할 수 있다. 다음 URL은 권한 요청을 보여준다:
http://localhost:8080/oauth2/authorize?response_type=code&client_id=client&scope=openid&redirect_uri=https://www.manning.com/authorized&code_challenge=[Challenge 코드]&code_challenge_method=S256
권한(authorization) 요청에서는 몇 가지 파라미터를 추가한 것을 볼 수 있다.
- response_type=code: 이 요청 파라미터는 클라이언트가 권한 코드 그랜트 타입을 사용하고자 한다는 것을 권한 서버에 알린다. 클라이언트는 여러 그랜트 타입을 구성했을 수 있으며, 사용하고자 하는 그랜트 타입을 권한 서버에 알려야 한다.
- client_id=client: 클라이언트 식별자는 사용자의 사용자 이름과 유사하다. 이는 시스템에서 클라이언트를 고유하게 식별하는 데 사용된다.
- scope=openid: 이 인증 시도로 클라이언트가 부여받고자 하는 스코프를 지정한다.
- redirect_uri=...: 인증에 성공한 후 권한 서버가 리디렉션할 URI를 지정한다. 이 URI는 현재 클라이언트에 대해 이전에 구성된 URI 중 하나여야 한다.
- code_challenge=…: PKCE로 강화된 권한 코드를 사용하는 경우, 권한 요청 시 코드 챌린지를 제공해야 한다. 클라이언트는 토큰을 요청할 때 검증자(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를 사용하는 방식을 시연한다. 이는 디폴트 값이며 권장되는 방식이다.
3. 클라이언트 자격 증명 부여 유형 실행
브라우저 주소창을 통해 구현한 권한 서버를 사용하여 클라이언트 자격 증명 그랜트 타입(client credentials grant type)을 시도해 보겠다. 클라이언트 자격 증명 그랜트 타입은 사용자의 인증이나 동의 없이 클라이언트가 액세스 토큰을 받을 수 있도록 허용하는 플로우이다.
가능하면 사용자 종속적인 그랜트 타입 (권한 코드)과 클라이언트 독립적인 그랜트 타입 (클라이언트 자격 증명)을 동시에 사용할 수 있는 클라이언트를 허용하지 않는 것이 좋다.
권한 구현은 권한 코드 그랜트 타입으로 얻은 액세스 토큰과 클라이언트 자격 증명 그랜트 타입으로 얻은 액세스 토큰의 차이를 구분하지 못할 수 있다. 따라서 이러한 경우에는 별도의 등록을 사용하는 것이 가장 좋으며, 가능하면 서로 다른 스코프를 통해 토큰 사용을 구분하는 것이 바람직하다.
예제 8은 클라이언트 자격 증명 그랜트 타입을 사용할 수 있도록 등록된 클라이언트를 보여준다. 여기에서 다른 스코프도 구성한 것을 볼 수 있다. 이 경우, CUSTOM은 내가 선택한 이름일 뿐이며, 스코프에 대해 원하는 이름을 선택할 수 있다. 선택한 이름은 일반적으로 스코프의 목적을 더 쉽게 이해할 수 있도록 만들어야 한다.
예를 들어, 이 애플리케이션이 리소스 서버의 가용 상태(liveness state)를 확인하기 위해 클라이언트 자격 증명 그랜트 타입을 사용해 토큰을 얻어야 한다면, 스코프 이름을 LIVENESS로 지정하는 것이 더 명확할 수 있다. 한 요청을 보내면 시뮬레이션할 수 있다.
▼ 예제 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);
}
클라이언트는 액세스 토큰을 얻기 위해 간단히 요청을 보내고, 자신의 자격 증명(클라이언트 ID와 비밀)을 사용해 인증한다.
다음 코드는 cURL을 사용한 토큰 요청을 보여준다.
curl -X POST 'http://localhost:8080/oauth2/token?grant_type=client_credentials&scope=CUSTOM' --header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='
권한 코드 그랜트 타입을 실행할 때 사용한 요청과 비교하면, 이 요청이 더 단순하다는 것을 알 수 있다. 클라이언트는 클라이언트 자격 증명 그랜트 타입을 사용하며, 토큰을 요청할 스코프만 명시하면 된다. 클라이언트는 HTTP Basic 인증을 통해 자신의 자격 증명을 사용하여 요청한다.
아래 코드는 요청된 액세스 토큰이 포함된 HTTP 응답 본문을 보여준다.
{
"access_token": "eyJraWQiOiI4N2E3YjJiNS…",
"scope": "CUSTOM",
"token_type": "Bearer",
"expires_in": 300
}
4. 불투명 토큰 및 자체 검사 사용
권한 코드 그랜트 타입과 클라이언트 자격 증명 그랜트 타입 모두에서 클라이언트를 구성하여 투명 액세스 토큰을 얻을 수 있었다. 그러나 클라이언트를 불투명(opaque) 토큰을 사용하도록 간단히 구성할 수도 있다.
예제 9는 클라이언트를 불투명 토큰을 사용하도록 구성하는 방법을 보여준다. 불투명 토큰은 모든 그랜트 타입과 함께 사용할 수 있다는 점을 기억해야 한다. 여기서는 논의의 초점을 유지하기 위해 클라이언트 자격 증명 그랜트 타입을 사용하여 단순하게 구성했다. 또한, 권한 코드 그랜트 타입을 통해서도 불투명 토큰을 생성할 수 있다.
▼ 예제 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);
}
액세스 토큰을 요청하면 불투명(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)라고 한다.
아래 코드는 권한 서버에서 제공하는 내부 검사(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,
}
토큰의 기본 활성 시간은 300초다.
예제를 테스트할 때는 토큰의 수명을 더 길게 설정하는 것이 편리하다. 기본값을 그대로 두면, 테스트 중 토큰이 만료될 가능성이 높아 불편할 수 있다.
예제 10은 토큰의 수명을 조정하는 방법을 보여준다. 이 예제에서는 토큰의 수명을 12시간으로 설정했지만, 실제 애플리케이션에서는 이렇게 긴 수명을 사용하는 것이 권장되지 않는다. 일반적으로 10분에서 30분 사이로 설정하는 것이 보안상 적절하다.
▼ 예제 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();
5. 토큰 취소
토큰이 도난당한 경우, 해당 토큰을 무효화하려면 토큰 철회(Token Revocation)를 사용할 수 있다.토큰 철회는 권한 서버에서 발급된 액세스 토큰을 무효화하는 과정이다.
액세스 토큰은 일반적으로 수명이 짧기 때문에 도난당해도 사용이 어려운 경우가 많다. 하지만 특별한 경우에는 추가적인 조치가 필요할 수 있다.
다음은 권한 서버에서 제공하는 토큰 철회 엔드포인트에 요청을 보내는 cURL 명령이다.
Spring Security 권한 서버에서는 디폴트로 철회 기능이 활성화되어 있어, 이 기능을 통해 토큰을 무효화할 수 있다.
이 요청에는 철회할 토큰과 클라이언트 자격 증명을 사용한 HTTP Basic 인증이 필요하다.
요청을 보내면 해당 토큰은 즉시 사용할 수 없게 된다.
curl -X POST 'http://localhost:8080/oauth2/revoke?token=N7BruErWm-44-…'
--header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='
철회된 토큰을 내부 검사(introspection) 엔드포인트를 사용하여 확인하면,
비록 토큰의 수명이 만료되지 않았더라도 철회된 토큰은 더 이상 활성화되지 않은 상태임을 확인할 수 있다.
내부 검사 요청의 응답에서 active 상태가 false로 표시되며, 해당 토큰은 더 이상 유효하지 않음을 알 수 있다.
따라서, 토큰 철회 후에는 철회된 토큰을 사용하여 리소스에 접근할 수 없게 된다.
curl -X POST 'http://localhost:8080/oauth2/introspect?token=N7BruErWm-44-…'
--header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='
토큰 철회를 사용하는 것이 항상 필요한 것은 아니다. 철회 기능을 활성화하면, 모든 호출에서 토큰이 여전히 활성 상태인지 확인하기 위해 내부 검사(introspection)를 사용해야 한다는 점을 기억해야 한다. 이는 투명(non-opaque) 토큰의 경우에도 마찬가지이다. 내부 검사를 자주 수행하면 성능에 큰 영향을 미칠 수 있으므로, 그만큼 필요성을 신중하게 고려해야 한다. "이 추가적인 보호 계층이 정말 필요한가?"라는 질문을 스스로에게 던져야 한다.
curl -X POST 'http://localhost:8080/oauth2/introspect?token=N7BruErWm-44-…'
--header 'Authorization: Basic Y2xpZW50OnNlY3JldA=='
어떤 경우에는 간단한 해결책만으로 충분할 수 있다. 복잡한 시스템을 필요로 하지 않을 때, 간단한 방법으로 문제를 해결할 수 있다. 반면, 다른 경우에는 고급스럽고 복잡한 경보 시스템이 필요할 수 있다. 결국 무엇을 사용할지는 보호하려는 것의 중요도에 따라 달라진다.
'마이크로서비스 아키텍처' 카테고리의 다른 글
OAuth 2 클라이언트 구현 (0) | 2024.12.22 |
---|---|
Vault란 무엇인가? (0) | 2024.12.11 |