最初は、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
処理の流れは以下の通り。
- 最初にライフサイクルフックの
created
にて、Google OAuth認証機能の初期化を行なう。 - 初期値として
loggedIn = false
を設定する。 -
loggedIn = false
なので、Hoge
コンポーネントは非表示になり、Login
コンポーネントが表示される。 - Loginコンポーネントの
succeeded
というイベントに対してloginSucceeded
を紐づける。Loginコンポーネント側で認証が完了したらsucceeded
をEmitする。 -
scceeded
イベントがEmitされたら、loginSucceeded
メソッドがcallされ、loggedIn = true
が設定される。 -
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
処理の流れは以下の通り。
-
@Emit
によって、succeeded
イベントが定義される -
loginChecked = false
が設定される。 - ライフサイクルフックの
mounted
にてバックエンドサーバに認証済みかどうかをajaxで問い合わせる。- バックエンドサーバはセッションクッキーを元に認証済みかどうかを判断し、認証済みなら
200
を未認証ならば401
が返す。
- バックエンドサーバはセッションクッキーを元に認証済みかどうかを判断し、認証済みなら
-
401
が返ってきたら、loginChecked = true
を設定する。 -
loginChecked = true
が設定されたので、authenticate
ボタンが表示される。 - ユーザが
authenticate
ボタンをクリックすると、Google OAuth認証リクエストがaccount.google.comへ送信される。 - 認証が完了したら、レスポンスから
idToken
を取得する。 -
idToken
を元に、アプリのログイン処理を呼び出す。 - ログインが成功したら、
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が呼び出されることを想定している。
- セッションが存在しない状態で、セッション認証対象の
/logged_in
が呼び出される。- セッションが存在しないので
401 Unauthorized
を返却する。
- セッションが存在しないので
- 次にセッション認証対象外の
/login
が呼び出される。- セッションが存在しなくても、セッション認証の対象外なので
401
にはならない。 - Google OAuth認証に必要な
id_token
を受け取り、認証処理signIn
を呼び出す。 - 認証処理
signIn
の中では以下を実施している。- Google APIsで作成したOAuth 2.0 クライアントIDと、
id_token
を元にGoogle OAuthのAPIを呼び出す。 - 認証が成功したら、APIレスポンスから必要な情報を取得する。
- 取得したアカウント情報をDBに登録する。(アカウント毎に表示するコンテンツを制御したりすることを想定しているのでアプリ内で管理したい)
- セッションにアカウント情報を設定する。
- Google APIsで作成したOAuth 2.0 クライアントIDと、
- 最終的には、
200 OK
を返却する。
- セッションが存在しなくても、セッション認証の対象外なので
- 最後はセッション認証対象の
/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))
}
所感
やると決めてからの実装自体は結構早くできたが、どうにか公式の方法でうまくやれないかを調べるのはめちゃくちゃ時間がかかった。まだ利用者が少ないフレームワークの欠点だと思う。あと、自前実装にした結果、ブラックボックス感が一切なくなり全て制御可能になったことは大きなメリットだと思う。