Spring 기반 OAuth 2.1 Authorization Server 개발 찍먹해보기

Spring 기반 OAuth 2.1 Authorization Server 개발 찍먹해보기

시작하며

안녕하세요. 카카오페이 머니코어제휴파티에서 계좌광고플랫폼 서버를 개발하고 있는 제이입니다. 제휴사와 서버 간 API 통신을 구현하면서 보안 수준을 향상시키기 위해 OAuth를 도입하게 되었는데요. 처음으로 OAuth Authorization Server를 개발하게 되어 다소 막막했으나 마침 스프링 커뮤니티에서 활발히 개발되고 있는 Spring Authorization Server 프레임워크를 발견하여 적용해보게 되었습니다.

막막했던 예상과 다르게 Spring Authorization Server의 풍부한 Reference와 간편한 설정 방법을 바탕으로 Authorization Server를 쉽게 구축할 수 있었는데요. Authorization Server에 관심 있으시거나 구현하실 분들께 제 경험이 도움이 되었으면 하는 바람으로 본 포스팅을 작성하게 되었습니다.

Spring Authorization Server은 OAuth 2.1의 스펙을 구현하는 것을 목표로 하고 있습니다. 본격적인 구현에 앞서 OAuth 2.1의 개념을 소개해보겠습니다.

OAuth 2.1

OAuth 2.1은 The OAuth 2.0 Authorization Framework - RFC6749에 명시된 OAuth 2.0 스펙을 보안적으로 더욱 개선시킨 버전이라고 볼 수 있습니다. 버전이 올라가면서 개선된 변경사항은 OAuth 커뮤니티 사이트에서 확인하실 수 있습니다.

OAuth 워킹그룹에서 작성중인 OAuth 2.1 스펙 문서 초안을 보면 OAuth를 ‘OAuth Authorization Framework’라고 풀이하며 인가에 대해 수많은 스펙과 플로우를 정의하고 있습니다. OAuth는 서드파티 앱이 접근 제한된 서비스 자원(Protected Resource)을 사용하기 위해 Resource Owner(사용자)의 허락을 받아 자원을 사용할 수 있는 일련의 플로우를 기술하는 사실상의 인가표준(de facto) 기술입니다. IT 업계를 이끄는 유수의 IT 기업들은 강력한 회원 체계, 여러 코어 서비스를 바탕으로 OAuth 서비스를 제공하고 있습니다.

다음으로, OAuth 2.1이 어떻게 인가를 수행하는지 Protocol Flow를 살펴보겠습니다.

Protocol Flow

OAuth 2.1의 구성요소를 살펴보겠습니다.

  • Client: Protected Resource를 사용하고 싶어하는 주체이며 웹앱, 모바일앱, 기타 프로그램 등 Resource Owner가 Service에 접근할 때 사용하는 프로그램을 의미합니다.
  • Resource Owner: Protected Resource에 대한 접근을 허용하는 주체이며 대부분의 IT 서비스에서는 End-User(사용자)를 의미합니다.
  • Authorization Server: Resource Owner의 인가를 확인하고 Access Token을 발급하는 서버입니다.
  • Resource Server: Access Token을 검증하고 Protected Resource를 제공하는 서버입니다.

Protected Resource: Resource Owner, 즉 사용자가 허용해야만 사용할 수 있는 접근 제한된 자원을 말합니다. 카카오페이의 서비스에서는 사용자의 송금내역, 송금하기 등 사용자의 데이터나 액션을 예로 들 수 있습니다.

Protocol Flow를 간단히 살펴보면 아래와 같습니다.

  • (A), (B): Resource Owner 인증 및 인가
  • (C), (D): 인가를 통한 Access Token 획득
  • (E), (F): Access Token으로 Protected Resource 접근 권한 획득

다음으로, 제휴사와 서버 간 보안이 강화된 API 통신을 하기 위해 구현한 Client Credentials Grant에 대해 알아보겠습니다.

Client Credentials Grant

저희는 제휴사(Client)와 서버간 API 통신이 필요하므로 OAuth 인가 방식 중 서버 간 통신에 필요한 Client Credentials Grant를 구현하였습니다.

Client Credentials Grant는 Client와 Authorization Server 사이에 SSL 통신 등 신뢰할 수 있는 구간에서 활용할 수 있는 방식입니다. Client는 Access Token을 획득한 다음 Resource Server에 접근하여 Protected Resource를 사용할 수 있습니다. 이 Protocol Flow가 정상 동작하기 위해서는 Client Authentication에 사용할 자격증명(Client ID, Client Secret)이 Client에게 먼저 공유되어 있어야 합니다.

지금까지 OAuth 2.1의 구성 요소와 Protocol Flow를 살펴보고, 저희가 구현한 Client Credentials Grant에 대해 소개해 드렸습니다.

다음으로, 저희가 선택한 Spring Authorization Server에 대해 설명해드리겠습니다.

Spring Authorization Server

OAuth Authorization Server에 대해 여러 구현 프로젝트가 있지만 23년 3월 기준으로 가장 활발히 개발되고 있는 Spring Authorization Server를 선택했습니다. 이 프레임워크는 Spring Security의 SecurityFilterChain 기반으로 동작하므로 Spring Security에 대해 알고 있어야 더욱 편하게 개발할 수 있습니다.

위 문서는 공식 Reference 문서이며, Access Token을 발급하는 부분은 Protocol Endpoints -> OAuth2 Token Endpoint를 확인하면 됩니다.

Token Endpoint 가이드에서는 SecurityFilterChain에 포함되는 AccessTokenRequestConverter, AuthenticationProvider, AccessTokenResponseHandler 등 다양한 클래스에 대해 커스터마이징을 지원합니다. 그러면서도 Spring Authorization Server가 OAuth 2.1 프레임워크의 스펙을 충실히 커버하므로 기본 설정만으로도 OAuth 환경 구성할 수 있습니다.

Client Authentication에 필요한 자격증명(Client ID, Client Secret)은 RegisteredClientRepository을 이용하여 읽고 쓰면 되는데, Spring Authorization Server는 In-Memory, Jdbc 구현체를 기본적으로 제공하고 있습니다. 본 포스팅에서는 프레임워크의 Authorization Server Flow에 집중하기 위해 구현하기 쉬운 In-Memory 구현체를 선택했습니다.

Security Configuration

Spring Authorization Server는 SecurityFilterChain으로 설정할 수 있습니다.

@Bean
fun filterChain(
    http: HttpSecurity,
    // ...
): SecurityFilterChain {
    OAuth2AuthorizationServerConfigurer()
        .apply { http.apply(this) } // OAuth2AuthorizationServerConfigurer 등록
        .registeredClientRepository(registeredClientRepository) // Client Credentials 데이터 저장소 등록
        .authorizationService(authorizationService) // OAuth Authorization (Access Token) 내역 데이터 저장소 등록
        .tokenGenerator(JwtGenerator(jwtEncoder)) // Access Token은 JWT 선택 (기본 구현은 Opaque Token)
        .authorizationServerSettings(settings) // Authorization Server 환경 셋팅 (예: Token Endpoint 커스터마이징)
    // ...
    return http.build()
    }

Protocol Endpoints 문서에 풍부한 예제가 제공되므로 원하는 요구사항을 쉽게 구현할 수 있습니다. 프레임워크 기본 Token 타입은 Opaque Token인데 만약 JWT(JSON Web Token)를 사용하고자 한다면 위 예제처럼 tokenGenerator() 메서드를 이용하여 커스터마이징하면 됩니다.

그리고 Client Authentication에 쓰이는 RegisteredClientRepository의 구현체를 작성했습니다. tokenSetings() 메서드를 통해 Access Token의 유효시간도 설정할 수 있습니다.

@Bean
fun registeredClientRepository(): RegisteredClientRepository {
    val registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
        // ...
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) // Authorization: Basic {base64 인코딩 문자열}
        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) // client_credentials 이용
        .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofHours(2)).build()) // Token 유효 2시간으로 설정
        .build()
    return InMemoryRegisteredClientRepository(registeredClient)
}

위처럼 In-Memory로 구성을 하는 것은 무척 쉽지만 실제로 현업에서는 JPA를 많이 사용하실텐데요. JPA를 사용하는 수요에 맞춰 JPA 구현체 예제도 제공하고 있으므로 개발의 수고를 줄일 수 있습니다.

아래는 JWTSource를 포함한 기타 설정 코드입니다.

@Bean
fun authorizationService(): OAuth2AuthorizationService =
    InMemoryOAuth2AuthorizationService()

/**
 * jwt 생성에 필요한 RSA키 generate, 실제 운영에 사용하려면 KeyStore에 저장해야한다.
 */
@Bean
fun jwkSource(): JWKSource<SecurityContext> {
    val keyPair: KeyPair = generateRsaKey()
    val publicKey: RSAPublicKey = keyPair.public as RSAPublicKey
    val privateKey: RSAPrivateKey = keyPair.private as RSAPrivateKey
    val rsaKey: RSAKey = RSAKey.Builder(publicKey)
        .privateKey(privateKey)
        .keyID(UUID.randomUUID().toString())
        .build()
    val jwkSet = JWKSet(rsaKey)
    return ImmutableJWKSet(jwkSet)
}

private fun generateRsaKey(): KeyPair =
    try {
        val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
        keyPairGenerator.initialize(2048)
        keyPairGenerator.generateKeyPair()
    } catch (ex: Exception) {
        throw IllegalStateException(ex)
    }

@Bean
fun jwtDecoder(jwkSource: JWKSource<SecurityContext>): JwtDecoder =
    OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource)

@Bean
fun jwtEncoder(jwkSource: JWKSource<SecurityContext>): JwtEncoder =
    NimbusJwtEncoder(jwkSource)

/**
 * 여러 endpoint URI를 커스터마이징할 수 있다.
 */
@Bean
fun authorizationServerSettings(): AuthorizationServerSettings =
    AuthorizationServerSettings.builder().tokenEndpoint("/v1/oauth2/token").build()

Spring Security를 이용하여 OAuth 셋팅을 간단하게 마쳤습니다.
다음으로, Security 설정이 잘 되었는지 확인하기 위해 API 코드를 작성하겠습니다.

Resource Server API 작성

API 서버는 OAuth에서 Resource Server를 의미합니다. OAuth 테스트를 위해 간단한 API 코드를 작성했습니다.

@RequestMapping("/v1/users")
@RestController
class UserController {

    @GetMapping("/me")
    fun getMyInfo(): UserInfoResponse =
        UserInfoResponse("jay")

    data class UserInfoResponse(
        val nickname: String,
    )
}

API 테스트

보통 Authorization Server와 Resource Server를 분리하는 것이 기본적인 구현인데요. 프레임워크의 기능 테스트가 목적이므로 본 예제에서는 Authorization Server와 Resource Server를 동일한 인스턴스에서 구동했습니다. 그럼 Authorization Server가 잘 동작하는지 테스트해보겠습니다.

1. 실패 케이스) Bearer Token 없이 API 호출

curl -X GET http://localhost:8080/v1/users/me -v
< HTTP/1.1 401
< WWW-Authenticate: Bearer

Bearer Token 없이 API를 호출하면 401 Unauthorized 오류와 함께 WWW-Authenticate: Bearer 헤더 즉 Bearer 타입으로 인증하라는 것을 알 수 있습니다.

2. 실패 케이스) Authorization (client_secret_basic) 헤더를 생략한 Access Token 발급 시도

curl -X POST http://localhost:8080/v1/oauth2/token\?grant_type\=client_credentials \
--header 'Content-Type: application/x-www-form-urlencoded' -v
< HTTP/1.1 401

Basic으로 시작하는 문자열을 포함한 Authorization 헤더가 없으므로 Client Authentication에 실패하여 401 Unauthorized 에러가 발생합니다.

3. 성공 케이스) Authorization 헤더와 함께 Access Token 발급 시도

필요한 Authorization(client_secret_basic) 헤더의 포맷은 Authorization: Basic {base64 인코딩 문자열}입니다. {clientId}:{clientSecret}의 문장을 base64 인코딩하면 구할 수 있습니다.

clientSecret을 잘못 입력한 경우 401헤더와 함께 {"error":"invalid_client"} 같은 응답이 내려오는 것도 확인할 수 있습니다. Spring Security에서는 DelegatingPasswordEncoder을 이용하여 clientSecret을 검증하므로 clientSecret을 암호화하여 DB에 저장할 때는 DelegatingPasswordEncoder를 이용해야 합니다.

올바른 스펙으로 Token 발급 API를 호출하면 정상적으로 Access Token을 얻을 수 있습니다.

curl -X POST http://localhost:8080/v1/oauth2/token\?grant_type\=client_credentials \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic S2syaW04...(생략)' -v
< HTTP/1.1 200
{"access_token":"eyJraWQiOiIzZTk2OGI0YS04Zjk2LTRkMjMtYjc5MS02MmYzMGRiYWE1M2MiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhYWFhIiwiYXVkIjoiYWFhYSIsIm5iZiI6MTY3Nzk1MzQzOSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiZXhwIjoxNjc3OTYwNjM5LCJpYXQiOjE2Nzc5NTM0Mzl9.BApI_joRvSMBcQE4c3U86oJxKwCTf3EGgC-6pNGQ7sL3PvZ6FF1S5sNUC9RqqV5qRtDxLVV1CL3h9Elzz-AzyuH3Dnvky-VGbbtnl01fqlvHX33ovEmLDLWR_wWwC4NXiClD1ad-jamOO4bvd_TVPj84W7-Ok9Sza74X5jlAYK-l0Zca8J-GpRhF92wr7UDCdPdde_FKk99dO3LWf-qbklQBgitnbgUYc1but-fRypKoTfa8uT0NCd8pw3OkDSLuJ8rVvbqCWH5ugHZtt0Z1ZasVnMKz9XFjkpcpifIr-zT9-g807zoCFktS0pFN5aMcWpENV30EQDvyOCr94-nRwA","token_type":"Bearer","expires_in":7199}

API 응답을 보면 Token Response 스펙을 충실히 따라 access_token은 JWT 포맷으로 잘 생성되었고 token_type은 Bearer, expires_in은 7199초로 2시간인 것을 알 수 있습니다.

4. 성공 케이스) Access Token으로 API 호출

curl -X GET localhost:8080/v1/users/me \
--header 'Authorization: Bearer eyJraWQiOiIzZTk2OGI0YS04Zjk2LTRkMjMtYjc5MS02MmYzMGRiYWE1M2MiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhYWFhIiwiYXVkIjoiYWFhYSIsIm5iZiI6MTY3Nzk1MzQzOSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiZXhwIjoxNjc3OTYwNjM5LCJpYXQiOjE2Nzc5NTM0Mzl9.BApI_joRvSMBcQE4c3U86oJxKwCTf3EGgC-6pNGQ7sL3PvZ6FF1S5sNUC9RqqV5qRtDxLVV1CL3h9Elzz-AzyuH3Dnvky-VGbbtnl01fqlvHX33ovEmLDLWR_wWwC4NXiClD1ad-jamOO4bvd_TVPj84W7-Ok9Sza74X5jlAYK-l0Zca8J-GpRhF92wr7UDCdPdde_FKk99dO3LWf-qbklQBgitnbgUYc1but-fRypKoTfa8uT0NCd8pw3OkDSLuJ8rVvbqCWH5ugHZtt0Z1ZasVnMKz9XFjkpcpifIr-zT9-g807zoCFktS0pFN5aMcWpENV30EQDvyOCr94-nRwA
' -v
> Authorization: Bearer eyJraWQiOiIzZTk2OGI0YS04Zjk2LTRkMjMtYjc5MS02MmYzMGRiYWE1M2MiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhYWFhIiwiYXVkIjoiYWFhYSIsIm5iZiI6MTY3Nzk1MzQzOSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiZXhwIjoxNjc3OTYwNjM5LCJpYXQiOjE2Nzc5NTM0Mzl9.BApI_joRvSMBcQE4c3U86oJxKwCTf3EGgC-6pNGQ7sL3PvZ6FF1S5sNUC9RqqV5qRtDxLVV1CL3h9Elzz-AzyuH3Dnvky-VGbbtnl01fqlvHX33ovEmLDLWR_wWwC4NXiClD1ad-jamOO4bvd_TVPj84W7-Ok9Sza74X5jlAYK-l0Zca8J-GpRhF92wr7UDCdPdde_FKk99dO3LWf-qbklQBgitnbgUYc1but-fRypKoTfa8uT0NCd8pw3OkDSLuJ8rVvbqCWH5ugHZtt0Z1ZasVnMKz9XFjkpcpifIr-zT9-g807zoCFktS0pFN5aMcWpENV30EQDvyOCr94-nRwA
< HTTP/1.1 200
{"nickname":"jay"}%

OAuth 플로우 스펙대로 API를 호출해보니 Access Token 발급 및 Protected Resource를 성공적으로 사용할 수 있음을 확인하였습니다. Authorization Server가 어떻게 잘 동작할 수 있는지 Spring Security 프레임워크를 분석해보겠습니다.

Spring Security 프레임워크 분석

Spring Security는 Servlet Filter를 추상화하는 FilterChainProxy 기반으로 동작하는 프레임워크입니다. 따라서 FilterChainProxy에서 Authorization Server를 담당하는 Filter들을 살펴보면 동작 원리를 알 수 있습니다.

FilterChainProxy 중 사용된 Filter 목록

위 이미지는 FilterChainProxy의 filterChains 필드에 등록된 필터들을 보여줍니다. Spring Security가 제공하는 기본 필터 외에도 Spring Authorization Server 구동에 필요한 필터(OAuth*, Nimbus*)도 약 10개 정도 추가되어 있는 것을 볼 수 있습니다.

이 중 빨간 사각형으로 표시된 3개의 필터가 Client Credentials Grant 플로우에 사용되었습니다. (아래)

OAuth2ClientAuthenticationFilter: Client로부터 받은 인증 요청을 처리하는 필터

  • ClientSecretBasicAuthenticationConverter: client_secret_basic 헤더에서 clientId, clientSecret 추출
  • ClientSecretAuthenticationProvider: clientId, clientSecret 검증

OAuth2TokenEndpointFilter: Token 발급 endpoint 필터

  • OAuth2ClientCredentialsAuthenticationConverter: client_credentials 방식을 지원하는 OAuth2ClientCredentialsAuthenticationToken 생성
  • OAuth2ClientCredentialsAuthenticationProvider: OAuth2ClientCredentialsAuthenticationToken을 바탕으로 Access Token을 생성

BearerTokenAuthenticationFilter: Bearer Token을 인증하는 필터

  • JwtGrantedAuthoritiesConverter: JWT에서 인가 정보 추출
  • JwtAuthenticationConverter: JwtAuthenticationToken Token 생성
  • JwtAuthenticationProvider: JWT 인증 및 인가 검증

위에서 살펴본 Filter 외에도 Authorization Server Metadata Endpoint, Token Introspection, Token Revocation, JWT Set 등 다양한 스펙을 지원하는 Filter들이 있는데요. 이 Filter들을 어떻게 다룰 수 있는지 궁금하신 분은 Protocol Endpoints에서 더욱 자세히 살펴보시길 추천드립니다.

마치며

본 포스팅에서는 Spring Authorization Server를 이용하여 OAuth 2.1 Authorization Server를 구축하는 방법을 공유드렸습니다. Spring Authorization Server는 OAuth의 방대한 스펙을 모두 지원하는 것 뿐만 아니라 Spring Security 기반으로 작성되어 있어 간단한 설정만으로 어떠한 OAuth 스펙이든지 쉽게 개발할 수 있는 강력한 프레임워크입니다. Authorization Server 첫 개발이라서 구현 시작 전에는 다소 막막했지만 Spring Security 기반의 친숙한 설정과 풍부한 프레임워크 Reference를 바탕으로 쉽고 편하게 개발할 수 있었습니다. Spring 프레임워크를 기반으로 개발 업무를 하시는 분들에게 더할 나위 없는 OAuth 프레임워크라고 감히 추천을 드리며 글을 마치겠습니다. 감사합니다.

References

jay.pg
jay.pg

카카오페이 머니코어제휴파티에서 다양한 서버를 개발하고 있는 제이입니다. 금융 산업 전반에 관심이 많아 하루하루 재밌게 일하고 있습니다.

태그