Skip to content

Ktor 클라이언트의 베어러 인증

필수 종속성: io.ktor:ktor-client-auth

코드 예시: client-auth-oauth-google

베어러 인증(Bearer authentication)은 베어러 토큰(bearer tokens)이라는 보안 토큰을 포함합니다. 예를 들어, 이러한 토큰은 OAuth 흐름(OAuth flow)의 일부로 Google, Facebook, Twitter 등과 같은 외부 공급자를 사용하여 애플리케이션 사용자를 인증하는 데 사용될 수 있습니다. Ktor 서버의 OAuth 권한 부여 흐름 섹션에서 OAuth 흐름이 어떻게 작동하는지 알아볼 수 있습니다.

서버에서 Ktor는 베어러 인증을 처리하기 위해 Authentication 플러그인을 제공합니다.

베어러 인증 구성

Ktor 클라이언트는 Authorization 헤더에 Bearer 스킴을 사용하여 보낼 토큰을 구성할 수 있도록 합니다. 또한, 이전 토큰이 유효하지 않은 경우 토큰을 새로 고치는 로직을 지정할 수 있습니다. bearer 제공자를 구성하려면 다음 단계를 따르세요.

  1. install 블록 내에서 bearer 함수를 호출합니다.
kotlin
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.auth.*
//...
val client = HttpClient(CIO) {
    install(Auth) {
       bearer {
          // Configure bearer authentication
       }
    }
}
  1. loadTokens 콜백을 사용하여 초기 액세스 토큰과 리프레시 토큰을 얻는 방법을 구성합니다. 이 콜백은 로컬 저장소에서 캐시된 토큰을 로드하고 이를 BearerTokens 인스턴스로 반환하는 역할을 합니다.
kotlin
install(Auth) {
    bearer {
        loadTokens {
            // Load tokens from a local storage and return them as the 'BearerTokens' instance
            BearerTokens("abc123", "xyz111")
        }
    }
}

abc123 액세스 토큰은 Authorization 헤더에 Bearer 스킴을 사용하여 각 요청과 함께 전송됩니다.

HTTP
GET http://localhost:8080/
Authorization: Bearer abc123
  1. refreshTokens를 사용하여 이전 토큰이 유효하지 않은 경우 새 토큰을 얻는 방법을 지정합니다.
kotlin
install(Auth) {
    bearer {
        // Load tokens ...
        refreshTokens { // this: RefreshTokensParams
            // Refresh tokens and return them as the 'BearerTokens' instance
            BearerTokens("def456", "xyz111")
        }
    }
}

이 콜백은 다음과 같이 작동합니다.

a. 클라이언트는 유효하지 않은 액세스 토큰을 사용하여 보호된 리소스에 요청하고 401 (Unauthorized) 응답을 받습니다. > 여러 제공자가 설치된 경우, 응답에 WWW-Authenticate 헤더가 있어야 합니다.

b. 클라이언트는 새로운 토큰을 얻기 위해 refreshTokens를 자동으로 호출합니다.

c. 클라이언트는 이번에는 새로운 토큰을 사용하여 보호된 리소스에 자동으로 한 번 더 요청합니다.

  1. 선택적으로, 401 (Unauthorized) 응답을 기다리지 않고 자격 증명(credentials)을 보낼 조건을 지정할 수 있습니다. 예를 들어, 요청이 지정된 호스트로 이루어지는지 확인할 수 있습니다.
kotlin
install(Auth) {
    bearer {
        // Load and refresh tokens ...
        sendWithoutRequest { request ->
            request.url.host == "www.googleapis.com"
        }
    }
}

예시: 베어러 인증을 사용하여 Google API에 액세스

인증 및 권한 부여를 위해 OAuth 2.0 프로토콜을 사용하는 Google API에 액세스하기 위해 베어러 인증을 사용하는 방법을 살펴보겠습니다. Google 프로필 정보를 가져오는 client-auth-oauth-google 콘솔 애플리케이션을 조사할 것입니다.

클라이언트 자격 증명 얻기

첫 번째 단계로, Google API에 액세스하는 데 필요한 클라이언트 자격 증명(client credentials)을 얻어야 합니다.

  1. Google 계정을 만듭니다.
  2. Google Cloud Console을 열고 Android 애플리케이션 유형으로 OAuth client ID 자격 증명을 생성합니다. 이 클라이언트 ID는 권한 부여 승인을 얻는 데 사용됩니다.

OAuth 권한 부여 흐름

애플리케이션의 OAuth 권한 부여 흐름은 다음과 같습니다.

Console
(1)  --> 권한 부여 요청                리소스 소유자(Resource owner)
(2)  <-- 권한 부여 승인 (코드)           리소스 소유자(Resource owner)
(3)  --> 권한 부여 승인 (코드)           권한 부여 서버(Authorization server)
(4)  <-- 액세스 및 리프레시 토큰            권한 부여 서버(Authorization server)
(5)  --> 유효한 토큰으로 요청             리소스 서버(Resource server)
(6)  <-- 보호된 리소스                   리소스 서버(Resource server)
⌛⌛⌛    토큰 만료
(7)  --> 만료된 토큰으로 요청           리소스 서버(Resource server)
(8)  <-- 401 Unauthorized 응답            리소스 서버(Resource server)
(9)  --> 권한 부여 승인 (리프레시 토큰)  권한 부여 서버(Authorization server)
(10) <-- 액세스 및 리프레시 토큰            권한 부여 서버(Authorization server)
(11) --> 새 토큰으로 요청               리소스 서버(Resource server)
(12) <-- 보호된 리소스                   리소스 서버(Resource server)

각 단계가 어떻게 구현되는지, 그리고 Bearer 인증 제공자가 API에 액세스하는 데 어떻게 도움이 되는지 살펴보겠습니다.

(1) -> 권한 부여 요청

첫 번째 단계로, 원하는 권한을 요청하는 데 사용되는 권한 부여 링크를 빌드해야 합니다. 이를 위해 지정된 쿼리 매개변수를 URL에 추가해야 합니다.

kotlin
val authorizationUrlQuery = parameters {
    append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
    append("scope", "https://www.googleapis.com/auth/userinfo.profile")
    append("response_type", "code")
    append("redirect_uri", "http://127.0.0.1:8080")
    append("access_type", "offline")
}.formUrlEncode()
println("https://accounts.google.com/o/oauth2/auth?$authorizationUrlQuery")
println("Open a link above, get the authorization code, insert it below, and press Enter.")
  • client_id: 이전에 얻은 클라이언트 ID는 Google API에 액세스하는 데 사용됩니다.
  • scope: Ktor 애플리케이션에 필요한 리소스의 범위(scopes). 이 경우 애플리케이션은 사용자 프로필에 대한 정보를 요청합니다.
  • response_type: 액세스 토큰을 얻는 데 사용되는 승인 유형(grant type). 이 경우 권한 부여 코드(authorization code)를 얻어야 합니다.
  • redirect_uri: http://127.0.0.1:8080 값은 권한 부여 코드를 얻기 위해 루프백 IP 주소(Loopback IP address) 흐름이 사용됨을 나타냅니다.

    이 URL을 사용하여 권한 부여 코드를 받으려면 애플리케이션이 로컬 웹 서버에서 수신 대기하고 있어야 합니다. 예를 들어, Ktor 서버를 사용하여 쿼리 매개변수로 권한 부여 코드를 얻을 수 있습니다.

  • access_type: 콘솔 애플리케이션이 사용자가 브라우저에 없을 때 액세스 토큰을 새로 고쳐야 하므로 액세스 유형은 offline으로 설정됩니다.

(2) <- 권한 부여 승인 (코드)

이 단계에서는 브라우저에서 권한 부여 코드를 복사하여 콘솔에 붙여넣고 변수에 저장합니다.

kotlin
val authorizationCode = readln()

(3) -> 권한 부여 승인 (코드)

이제 권한 부여 코드를 토큰으로 교환할 준비가 되었습니다. 이를 위해 클라이언트를 생성하고 json 직렬 변환기(serializer)와 함께 ContentNegotiation 플러그인을 설치해야 합니다. 이 직렬 변환기는 Google OAuth 토큰 엔드포인트에서 받은 토큰을 역직렬화하는 데 필요합니다.

kotlin
val client = HttpClient(CIO) {
    install(ContentNegotiation) {
        json()
    }
}

생성된 클라이언트를 사용하여 권한 부여 코드와 기타 필요한 옵션을 폼 매개변수(form parameters)로 토큰 엔드포인트에 안전하게 전달할 수 있습니다.

kotlin
val tokenInfo: TokenInfo = client.submitForm(
    url = "https://accounts.google.com/o/oauth2/token",
    formParameters = parameters {
        append("grant_type", "authorization_code")
        append("code", authorizationCode)
        append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
        append("client_secret", System.getenv("GOOGLE_CLIENT_SECRET"))
        append("redirect_uri", "http://127.0.0.1:8080")
    }
).body()

그 결과, 토큰 엔드포인트는 JSON 객체로 토큰을 전송하고, 이는 설치된 json 직렬 변환기를 사용하여 TokenInfo 클래스 인스턴스로 역직렬화됩니다. TokenInfo 클래스는 다음과 같습니다.

kotlin
import kotlinx.serialization.*

@Serializable
data class TokenInfo(
    @SerialName("access_token") val accessToken: String,
    @SerialName("expires_in") val expiresIn: Int,
    @SerialName("refresh_token") val refreshToken: String? = null,
    val scope: String,
    @SerialName("token_type") val tokenType: String,
    @SerialName("id_token") val idToken: String,
)

(4) <- 액세스 및 리프레시 토큰

토큰을 받으면 저장소(storage)에 저장할 수 있습니다. 이 예시에서 저장소는 BearerTokens 인스턴스의 변경 가능한(mutable) 리스트입니다. 이는 해당 요소를 loadTokensrefreshTokens 콜백에 전달할 수 있음을 의미합니다.

kotlin
        val bearerTokenStorage = mutableListOf<BearerTokens>()

        bearerTokenStorage.add(BearerTokens(tokenInfo.accessToken, tokenInfo.refreshToken!!))

bearerTokenStorage는 클라이언트 구성 내에서 사용될 것이므로 클라이언트 초기화 전에 생성되어야 합니다.

(5) -> 유효한 토큰으로 요청

이제 유효한 토큰이 있으므로 보호된 Google API에 요청하여 사용자 정보를 가져올 수 있습니다. 먼저 클라이언트 구성을 조정해야 합니다.

kotlin
        val client = HttpClient(CIO) {
            install(ContentNegotiation) {
                json()
            }

            install(Auth) {
                bearer {
                    loadTokens {
                        bearerTokenStorage.last()
                    }
                    sendWithoutRequest { request ->
                        request.url.host == "www.googleapis.com"
                    }
                }
            }
        }

다음 설정이 지정됩니다.

  • 이미 설치된 json 직렬 변환기가 있는 ContentNegotiation 플러그인은 리소스 서버에서 JSON 형식으로 받은 사용자 정보를 역직렬화하는 데 필요합니다.

  • bearer 제공자가 있는 Auth 플러그인은 다음과 같이 구성됩니다.

    • loadTokens 콜백은 저장소에서 토큰을 로드합니다.
    • sendWithoutRequest 콜백은 보호된 리소스에 대한 액세스를 제공하는 호스트에만 401 (Unauthorized) 응답을 기다리지 않고 자격 증명을 보내도록 구성됩니다.

이 클라이언트는 보호된 리소스에 요청하는 데 사용될 수 있습니다.

kotlin
while (true) {

(6) <- 보호된 리소스

리소스 서버는 사용자 정보를 JSON 형식으로 반환합니다. 응답을 UserInfo 클래스 인스턴스로 역직렬화하고 개인적인 인사말을 표시할 수 있습니다.

kotlin
val userInfo: UserInfo = response.body()
println("Hello, ${userInfo.name}!")

UserInfo 클래스는 다음과 같습니다.

kotlin
import kotlinx.serialization.*

@Serializable
data class UserInfo(
    val id: String,
    val name: String,
    @SerialName("given_name") val givenName: String,
    @SerialName("family_name") val familyName: String,
    val picture: String,
    val locale: String
)

(7) -> 만료된 토큰으로 요청

어느 시점에서 클라이언트는 5단계와 같이 요청을 하지만 만료된 액세스 토큰으로 요청합니다.

(8) <- 401 Unauthorized 응답

리소스 서버는 401 무단(unauthorized) 응답을 반환하므로 클라이언트는 refreshTokens 콜백을 호출해야 합니다.

401 응답은 오류 세부 정보가 포함된 JSON 데이터를 반환하므로 응답을 받을 때 이 경우를 처리해야 합니다.

(9) -> 권한 부여 승인 (리프레시 토큰)

새 액세스 토큰을 얻으려면 refreshTokens를 구성하고 토큰 엔드포인트에 또 다른 요청을 해야 합니다. 이번에는 authorization_code 대신 refresh_token 승인 유형(grant type)을 사용합니다.

kotlin
install(Auth) {
    bearer {
        refreshTokens {
            val refreshTokenInfo: TokenInfo = client.submitForm(
                url = "https://accounts.google.com/o/oauth2/token",
                formParameters = parameters {
                    append("grant_type", "refresh_token")
                    append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
                    append("refresh_token", oldTokens?.refreshToken ?: "")
                }
            ) { markAsRefreshTokenRequest() }.body()
        }
    }
}

refreshTokens 콜백은 RefreshTokensParams를 리시버로 사용하며 다음 설정에 액세스할 수 있다는 점에 유의하세요.

  • client 인스턴스. 위 코드 스니펫에서는 폼 매개변수를 제출하는 데 사용됩니다.
  • oldTokens 속성은 리프레시 토큰에 액세스하여 토큰 엔드포인트로 전송하는 데 사용됩니다.

HttpRequestBuilder에서 노출되는 markAsRefreshTokenRequest 함수는 리프레시 토큰을 얻는 데 사용되는 요청을 특별히 처리할 수 있도록 합니다.

(10) <- 액세스 및 리프레시 토큰

새 토큰을 받은 후 저장소에 저장할 수 있으므로 refreshTokens는 다음과 같습니다.

kotlin
refreshTokens {
    val refreshTokenInfo: TokenInfo = client.submitForm(
        url = "https://accounts.google.com/o/oauth2/token",
        formParameters = parameters {
            append("grant_type", "refresh_token")
            append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
            append("refresh_token", oldTokens?.refreshToken ?: "")
        }
    ) { markAsRefreshTokenRequest() }.body()
    bearerTokenStorage.add(BearerTokens(refreshTokenInfo.accessToken, oldTokens?.refreshToken!!))
    bearerTokenStorage.last()
}

(11) -> 새 토큰으로 요청

이 단계에서 보호된 리소스에 대한 요청은 새 토큰을 포함하며 제대로 작동해야 합니다.

kotlin
val response: HttpResponse = client.get("https://www.googleapis.com/oauth2/v2/userinfo")

(12) <-- 보호된 리소스

401 응답이 오류 세부 정보가 포함된 JSON 데이터를 반환한다는 점을 감안할 때, 오류에 대한 정보를 ErrorInfo 객체로 받도록 샘플을 업데이트해야 합니다.

kotlin
val response: HttpResponse = client.get("https://www.googleapis.com/oauth2/v2/userinfo")
try {
    val userInfo: UserInfo = response.body()
    println("Hello, ${userInfo.name}!")
} catch (e: Exception) {
    val errorInfo: ErrorInfo = response.body()
    println(errorInfo.error.message)
}

ErrorInfo 클래스는 다음과 같습니다.

kotlin
import kotlinx.serialization.*

@Serializable
data class ErrorInfo(val error: ErrorDetails)

@Serializable
data class ErrorDetails(
    val code: Int,
    val message: String,
    val status: String,
)

전체 예시는 여기에서 찾을 수 있습니다: client-auth-oauth-google.