最初は、KtorでAPIサーバを作るにあたり、公式ページ を参考にバックエンドでGoogle OAuth認証を行う実装を行なった。
しかし、フロントエンドからAPIを呼び出した際に、
フロントエンド => バックエンド => (redirect) => account.google.com
の様な流れで、CORSエラーが発生することが分かった(詳細はこちら)。

リバースプロキシでフロントエンドとバックエンドのドメインを統一してみてもうまく解決できず、結局公式で紹介されているGoogle OAuth認証は諦め、自前で認証処理を実装することにした。

処理の流れ

フロントエンド側の実装

package.json

vue-google-oauth2の依存関係を追加する。

"dependencies": {
    "vue-google-oauth2": "^1.3.8"
  }

App.vue

処理の流れは以下の通り。

  1. 最初にライフサイクルフックのcreatedにて、Google OAuth認証機能の初期化を行なう。
  2. 初期値としてloggedIn = falseを設定する。
  3. loggedIn = falseなので、Hogeコンポーネントは非表示になり、Loginコンポーネントが表示される。
  4. Loginコンポーネントのsucceededというイベントに対してloginSucceededを紐づける。Loginコンポーネント側で認証が完了したらsucceededをEmitする。
  5. scceededイベントがEmitされたら、loginSucceededメソッドがcallされ、loggedIn = trueが設定される。
  6. loggedIn = trueになったので、Loginコンポーネントは非表示になり、Hogeコンポーネントが表示される。
<template>
  <div id="app">
    <div v-if=loggedIn>
      <Hoge :url=url />
    </div>
    <div v-else>
      <Login @succeeded=loginSucceeded />
    </div>
  </div>
</template>
:
import GAuth from 'vue-google-oauth2'
:
export default class App extends Vue {
  private loggedIn: boolean = false

  created (): void {
    const GOOGLE_OAUTH_OPTION = {
      clientId: '**********.apps.googleusercontent.com',
      scope: 'profile email',
      prompt: 'select_account'
    }
    Vue.use(GAuth, GOOGLE_OAUTH_OPTION)
  }

  @Emit()
  loginSucceeded (): void {
    this.loggedIn = true
  }
}

Login.vue

処理の流れは以下の通り。

  1. @Emitによって、succeededイベントが定義される
  2. loginChecked = falseが設定される。
  3. ライフサイクルフックのmountedにてバックエンドサーバに認証済みかどうかをajaxで問い合わせる。
    • バックエンドサーバはセッションクッキーを元に認証済みかどうかを判断し、認証済みなら200を未認証ならば401が返す。
  4. 401が返ってきたら、loginChecked = trueを設定する。
  5. loginChecked = trueが設定されたので、authenticateボタンが表示される。
  6. ユーザがauthenticateボタンをクリックすると、Google OAuth認証リクエストがaccount.google.comへ送信される。
  7. 認証が完了したら、レスポンスからidTokenを取得する。
  8. idTokenを元に、アプリのログイン処理を呼び出す。
  9. ログインが成功したら、succeededイベントをemitする。
<template>
  <div v-if=loginChecked>
    本機能を利用するためには、Google認証が必要です。<br/>
    <button @click="handleAuthenticateButton">authenticate</button>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue, Emit } from 'vue-property-decorator'
import axios from 'axios'

@Component
export default class Login extends Vue {
  @Emit() private succeeded () {}

  private loginChecked: boolean = false

  mounted (): void {
    axios
      .get('http://rinoguchi.io/logged_in')
      .then(response => {
        this.succeeded()
      })
      .finally(() => {
        this.loginChecked = true
      })
  }

  handleAuthenticateButton () {
    const gAuth = this.$root.$gAuth
    gAuth.signIn()
      .then(GoogleUser => {
        const idToken = GoogleUser.getAuthResponse().id_token
        this.loginApplication(idToken)
      })
      .catch(error => {
        alert('ポップアップがブロックされているかもしれません。ポップアップを許可してみてください。')
        console.log(error)
      })
  }

  loginApplication (idToken: string) {
    axios
      .get('http://rinoguchi.io/login', { params: { 'id_token': idToken } })
      .then(response => {
        this.succeeded() // 親の`succeeded`イベントをemit
      })
      .catch(error => {
        alert(' Login error occurred. detail: ' + error)
      })
  }
}
</script>

バックエンド側の実装

build.gradle

まずはGoogle APIsのclientライブラリの依存関係を追加する。

dependencies {
    compile "com.google.api-client:google-api-client:1.28.0"
}

MainApp.kt

  • セッションを有効にする。
    • secretSignKeyは環境変数をapplication.confで読み込んで、それを使うようにしてある。
    • これによりHOGE_SESSION_IDという名前のクッキーが生成されるようになる。
  • 認証を有効にする。
    • 色々な認証方式があるのだが、今回はセッション情報を元に認証を行う方式をとる。(OAuth認証は使わない)
    • 認証方式に名前session-authをつけてある。Rooting側で認証範囲を指定する際にこの名称を利用する。
    • validateメソッドに渡しているブロックの中身が認証処理の本体。
    • 認証が成功したらPrincipalを返し、不成功の場合はnullを返す必要がある。
    • 今回はDBからデータを取得できれば認証成功とみなし、Principalを、できなければnullを返す様にしてある
    • challengeに渡している内容は、認証失敗の時の挙動を指定するもの。
    • SessionAuthChallenge.Unauthorizedを指定すれば、401 Unauthorizedが返る。今回はこれを利用している。
    • SessionAuthChallenge.Redirectを指定すれば、特定のurlにリダイレクトすることできる
    • SessionAuthChallenge.Ignoreを指定すれば、何事もなく認証処理を終えて、後続処理を呼び出すこともできる
    • 今回は使ってないが、特定の条件で認証処理自体をスキップできるskipWhenも利用するケースはあると思う。
data class SessionAccount(val googleId: String, val googleName: String) 

fun Application.module() {
    // セッション
    install(Sessions) {
        cookie<SessionAccount>("HOGE_SESSION_ID") {
            val secretSignKey = hex(environment.config.property("app.session.secret_key").getString())
            transform(SessionTransportTransformerMessageAuthentication(secretSignKey))
        }
    }

    // 認証
    install(Authentication) {
        session<SessionAccount>("session-auth") {
            data class AccountPrincipal(val id: Int) : Principal
            validate { sessionAccount ->
                transaction { AccountEntity.findByGoogleId(sessionAccount.googleId) }?.let {
                    AccountPrincipal(it.id)
                }
            }
            challenge = SessionAuthChallenge.Unauthorized
        }
    }
}

Rooting

基本的には以下の流れで各URLが呼び出されることを想定している。

  1. セッションが存在しない状態で、セッション認証対象の/logged_inが呼び出される。
    • セッションが存在しないので401 Unauthorizedを返却する。
  2. 次にセッション認証対象外の/loginが呼び出される。
    • セッションが存在しなくても、セッション認証の対象外なので401にはならない。
    • Google OAuth認証に必要なid_tokenを受け取り、認証処理signInを呼び出す。
    • 認証処理signInの中では以下を実施している。
      • Google APIsで作成したOAuth 2.0 クライアントIDと、id_tokenを元にGoogle OAuthのAPIを呼び出す。
      • 認証が成功したら、APIレスポンスから必要な情報を取得する。
      • 取得したアカウント情報をDBに登録する。(アカウント毎に表示するコンテンツを制御したりすることを想定しているのでアプリ内で管理したい)
      • セッションにアカウント情報を設定する。
    • 最終的には、200 OKを返却する。
  3. 最後はセッション認証対象の/hoge/fugaが呼び出される。
    • この時点ではセッションが存在するので、セッション認証のvalidate処理を通り抜けて、アプリ本来の処理が実行される。
routing {
    // セッション認証対象外
    get("/login") {
        val idToken = call.parameters["id_token"]
        idToken ?: throw NotFoundException("idToken is not found")
        val clientId = application.environment.config.property("app.google.client_id").getString()

        call.respond(transaction { signIn(idToken, clientId, call.sessions) })
    }

    // セッション認証対象
    authenticate("session-auth") {
        get("/logged_in") {
            call.respondText("already logged in")
        }
        get("/hoge") {
            // do something
        }
        get("/fuga") {
            // do something
        }
    }
}

fun signIn(idToken: String, clientId: String, sessions: CurrentSession) {
    // idTokenを利用して、Google OAuth認証実行
    val verifier: GoogleIdTokenVerifier = GoogleIdTokenVerifier
        .Builder(NetHttpTransport(), JacksonFactory.getDefaultInstance())
        .setAudience(Collections.singletonList(clientId))
        .build()
    val token: GoogleIdToken = verifier.verify(idToken)

    // ユーザ情報取得
    val googleId = token.payload.subject!!
    val googleName = token.payload.email!!

    // アカウントがなければアカウントをDBに登録
    val account = AccountEntity.insertIfNotExists(googleId, googleName)

    // セッション情報を追加
    sessions.set(SessionAccount(googleId, googleName))
}

所感

やると決めてからの実装自体は結構早くできたが、どうにか公式の方法でうまくやれないかを調べるのはめちゃくちゃ時間がかかった。まだ利用者が少ないフレームワークの欠点だと思う。あと、自前実装にした結果、ブラックボックス感が一切なくなり全て制御可能になったことは大きなメリットだと思う。