KotlinでOAuth徹底入門のclientサーバーを実装する

nomoa,KotlinKtorOAuth

OAuth徹底入門を読んでjsでclinetサーバーを実装したのでKotlinでも実装することでOAuthを利用する側の基礎を復習する。以下には実装で詰まった箇所、気づいた点をメモしていく。

前提

Kotlin/jvm Kotiln 1.3.50 ktor 1.2.4 authorization server,protected resource serverはOAuth徹底入門のch-3-1ディレクトリにあるものを使用する。 nodeの都合からauthorization serverが正常に動作しないのでgithubのissue (opens in a new tab)を参考にする。2019/11/03時点で修正されていない。

TOPページ

    get("/") {
      call.respondHtml {
        body {
          h1 { +"OAuth徹底入門 with Kotlin" }
          a(href = "http://localhost:9000/authorize") { +"AUTHORIZE" }
        }
      }
    }

認証する前のためのhtmlを用意する。

Authorization Codeを要求。

    get("/authorize") {
      call.respondRedirect(false) {
        host = "localhost"
        port = 9001
        path("authorize")

        parameters["response_type"] = "code"
        parameters["client_id"] = ConstValue.CLIENT_ID
        parameters["redirect_uri"] = ConstValue.REDIRECT_URI

        val state = getRandomString()
        DB.states[ConstValue.CLIENT_ID] = state
        parameters["state"] = state
      }
    }

ポイントは上のstateパラメータです。OAuth徹底入門の7章にて解説されていますがCSRF対策のためにqueryパラメータの一つとして付与する必要がある。 また生成されるstateについては2^160以上のランダム性を持ったものが推奨されている。

fun getRandomString(): String {
  return BigInteger(160, SecureRandom()).toString(32)
}

getRandomString関数は160桁の2進数で生成された数値を32進数の文字列に変換して生成している。

ユーザーはAuthorization Serverが提供する認可UIを使用して許可

    get("/callback") {
      val code = call.request.queryParameters["code"]
      val state = call.request.queryParameters["state"]

      if (code == null || state == null || state != DB.states[ConstValue.CLIENT_ID]) {
        throw Exception("invalid authorization code")
      }

authorizationサーバーが叩くcallbackAPIにて受け取ったstateが攻撃者によってすり替えられたものではないのか検証する。

tokenを要求

      val req = Request.Builder().apply {
        val utf8 = StandardCharsets.UTF_8.toString()
        val clientIdByteArray = URLEncoder.encode(ConstValue.CLIENT_ID, utf8)
        val secretByteArray = URLEncoder.encode(ConstValue.CLIENT_SECRET, utf8)
        addHeader("Content-Type", "application/x-www-form-urlencoded")
        val idAndPass = Base64.encodeBase64String("$clientIdByteArray:$secretByteArray".toByteArray())
        addHeader("Authorization", "Basic $idAndPass")
        val body = "grant_type=authorization_code&code=$code&redirect_uri${ConstValue.REDIRECT_URI}".toRequestBody()
        method("POST", body)
        url("http://localhost:9001/token")
      }.build()
      val res = OkHttpClient().newCall(req).execute()
      val responseBody = Gson().fromJson(res.body!!.string(), TokenResp::class.java)!!
      ConstValue.TOKEN = responseBody.access_token

Authorization Code GrantではAuthorizationヘッダーを利用して認証することが推奨されている。これはtokenを交換する際も同様。 認証方法にてBasicが使用されている点とbodyがapplication/jsonではない点が慣れないが本質には関係ないので意識しない。

OAuth2にて保護されたリソースを要求

      val req = Request.Builder().apply {
        addHeader("Content-Type", "application/x-www-form-urlencoded")
        addHeader("Authorization", "Bearer ${ConstValue.TOKEN}")
        url("http://localhost:9002/resource")
        method("POST", "".toRequestBody())
      }.build()
      val res = OkHttpClient().newCall(req).execute()

今回はトークンの種類としてBearerトークンが指定されているのでAuthorizationヘッダーにのvalueのprefixとして"Bearer "をつける。 Bearerトークンはbase64でエンコードされている必要がある。clientは何も確認せず使用することができる。

まとめ

OAuth2は認可フローから作成されたフレームワークである。OpenIDConnectと同時に使用されることが多いので認証、認可を行えるものとして勘違いされるが今回使用した「Authorization Code Grant」を行うために最適化されたもの。これ以外にも認証フローはいくつか用意されているものの何かしらの脆弱性を許容していることを意識する。 clientサイドの実装を行う予定しか今のところないのでclientをkotlinにて実装したがそれ以外のサーバーを扱う必要があるときは今回のように別言語で実装し直してみると得られることが多い。

© nomoa.devRSS