KtorでOAuth2認証をしてみる


だいぶ遅くなってしまいましたが、この記事は[リクルートライフスタイル Advent Calendar 2017](https://qiita.com/advent-calendar/2017/recruitlifestyle)の2日目の記事です。

# はじめに
## Ktorとは

<img src="http://ktor.io/assets/images/ktor_logo.png" alt="Ktor" width="400" style="max-width:100%;">


[Ktor](http://ktor.io/)とは[JetBrains](https://www.jetbrains.com/)社が提供する、[kotlin](https://kotlinlang.org/)のwebフレームワークです。
"Easy to use, fun and asynchronous."を標榜しており、とても軽量で簡単に使えるのが特徴です。

この記事を書こうと思った11月末にはQiitaにktorのtagすら無い状態だったのですが、モタモタしている内にいくつかQiitaにも記事が投稿されていました。

kotlinをサポートしたWeb Frameworkといえば[Spring Framwork 5.0](https://spring.io/blog/2017/01/04/introducing-kotlin-support-in-spring-framework-5-0)が有名ですが、kotlinの提供元であるJetBrains製ということでKtorは安心感も高く、シンプルなものを作る時には選択肢になりうるのかなと思っております。

## 本記事の目的・モチベーション
公式ページでも説明が色々されているのですが、まだ未整備の部分も多く[認証に関するページ](http://ktor.io/features/authentication.html)もまだだったため、自分で試してみたいと思いたち、Githubの[該当部分の実装](https://github.com/Ktorio/Ktor/tree/master/Ktor-features/Ktor-auth)や[sample](https://github.com/Ktorio/Ktor/tree/master/Ktor-samples)を色々調べたため記事としてまとめました。
<img width="500" alt="Ktor authentication page screenshot" src="https://qiita-image-store.s3.amazonaws.com/0/58617/b7688270-1e84-e6f4-1768-d948689c7a94.png">


今回は、GitHubアカウントでOAuth2認証してログインし、ユーザ情報を表示するだけの簡単なアプリをサンプルとして作りました。
記事内のソースコードは[GitHub](https://github.com/haijima/ktor-study)にあげてあります。
![サンプル実装のgif](https://qiita-image-store.s3.amazonaws.com/0/58617/75313aa4-4dee-ca39-b8f9-378930161e81.gif)

なおkotlin, Ktorの使用したバージョンは下記となっております。

* kotlin: `1.2.0`
* Ktor: `0.9.0`

# Feature
今回使用する主要な`Feature`のそれぞれの簡単な説明です。

* Routing
* Sessions
* Authentication

まずは、使用する`Feature`をinstallします。

```App.kt
class App {
    fun Application.install() {
        install(Sessions)
        install(Routing)
    }
}
```

`Application`に直接 `Authentication`をinstallしていないかというと、特定のパスでのみOAuth2認証をするようにするためです。
詳しくは下記で説明します。

## Routing
まずは、`Routing`の設定をおこないます。
このFeatureはその名の通りrequetのルーティングを管理する`Feature`です。

こちらは本記事では詳しくは触れませんが、Routingをタイプセーフに行うために`Location Feature`もinstallし、各エンドポイントをclassとして宣言します。

それを用いて、`Route`の拡張関数として各エンドポイント毎の設定をすると、以下のようになります。
これでルーティングの設定ができている状態となります。

``` App.kt
@location("/") class Index()
@location("/login") class Login()
@location("/login/github") class LoginWithGitHub()

fun Route.index() {
    get<Index> {
        call.respondText { "TOPページ" }
    }
}

fun Route.login() {
    get<Login> {
        call.respondText { "ログインページ" }
    }
}

fun Route.loginWithGitHub() {
    get<LoginWithGitHub> {
        call.respondText { "ここからGitHubの認証ページにリダイレクトします" }
    }
}

class App {
    fun Application.install() {
        install(Sessions)
        install(Locations)
        install(Routing) {
            index()
            login()
            loginWithGitHub()
        }
    }
}
```

## Sessions
このFeatureを使うことでSessionへの値の保存・読み出しが可能となります。
値をCookieで渡すか、session idを発行するかセッションのストア先はどうするかなどを設定できます。
今回は、Cookieにsession idを発行して渡しインメモリで保存するように設定しました。

```App.kt
install(Sessions) {
    cookie<GitHubSession>("GitHubSession", SessionStorageMemory())
}
```

またここではSessionに保存する型も指定しています。
今回は`access_token`だけを持つようにしています。

```App.kt
data class GitHubSession(val accessToken: String)
```

## Authentication
やっと本題の認証の設定についてです。

* 認証済みであれば、Indexページへリダイレクト
* 未認証であれば、Loginページへリダイレクト
* Loginページ内の「GitHubでログイン」ボタンを押すとLoginWithGitHubページへ遷移、OAuth2認証開始

という動きにしたいと思います。

### Sessionから認証情報を読み出す。
まずは、Sessionから認証情報を読み出して判定するように、Routeの各拡張関数を修正します。

```App.kt
fun Route.index() {
    get<Index> {
        if (call.sessions.get<GitHubSession>() != null) {
            call.respondText { "TOPページ" }
        } else {
            call.respondRedirect(application.locations.href(Login()))
        }
    }
}

fun Route.login() {
    get<Login> {
        if (call.sessions.get<GitHubSession>() != null) {
            call.respondRedirect(application.locations.href(Index()))
        } else {
            call.respondHtml {
                head { title { +"Login" } }
                body { a(href = locations.href(LoginWithGitHub())) { +"GitHubでログイン" } }
            }
        }
    }
}
```
このように、`PipelineContext<*, ApplicationCall>`内で`call.sessions`とすることでSessionへとアクセスできます。

### OAuth2認証設定
次に、`LoginWithGitHub`ページでOAuth2認証を行うための設定をします。

#### サービスプロバイダや登録したクライアントの情報
まずはサービスプロバイダである[Githubの情報](https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/)と、そこへ[登録したサンプルアプリの情報](https://github.com/settings/developers)を`OAuth2ServerSettings`として宣言します。

```App.kt
// OAuth2の設定
private val gitHubOAuth2Settings = OAuthServerSettings.OAuth2ServerSettings(
        name = "github",
        authorizeUrl = "https://github.com/login/oauth/authorize",
        accessTokenUrl = "https://github.com/login/oauth/access_token",
        clientId = "xxxx",
        clientSecret = "xxxx",
        defaultScopes = listOf("read:user")
)
```

#### OAuth2認証のinstall
`Route`に`Authentication`をinstallすることで、該当のパスで認証のチェックが行われます。
`Authentication`の詳細として、先ほど宣言した`OAuth2ServerSettings`を用いてOAuth2認証をおこないます。
`oauthAtLocation`関数自体は標準のAPIで、OAuth1用の設定を渡せばOAuth1認証をおこなってくれます。
また、GitHubとのHTTP通信に`HttpClient`が必要になるためメソッド引数として受け取っています。

```App.kt
private val exec = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 4)

fun Route.loginWithGitHub(client: HttpClient) {
    location<LoginWithGitHub> {
        // Authentication Featureをinstall
        install(Authentication) {
            // OAuth2認証
            oauthAtLocation<LoginWithGitHub>(
                    client,
                    exec.asCoroutineDispatcher(),
                    providerLookup = { gitHubOAuth2Settings },
                    urlProvider = { location, _ -> "http://localhost:8080${application.locations.href(location)}" })
        }

        // 認証後(OAuth2フローでcallbackとして戻ってきてから)の処理)
        handle { TODO("認証後の処理") }
    }
}
```

また、今まで`get<LoginWithGitHub>`となっていた部分が`location<LoginWithGitHub>`となっていることに気づいたかもしれませんが、実は元々の`get`関数は`location`関数のラッパーなのですが、これを使うと`Route`に認証処理を挟み込めないため`location`関数を使うように変更しています。

```Location.kt
inline fun <reified T : Any> Route.get(noinline body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Unit): Route {
    return location(T::class) {
        method(HttpMethod.Get) {
            handle(body)
        }
    }
}
```

#### 認証後の処理
先ほど省略していた認証後の処理の中身ですが、取得した認証情報をSessionに格納してTOPページへリダイレクトします。
取得した認証情報は`call.principal<OAuthAccessTokenResponse.OAuth2>()`で取得できます。

```App.kt
        // 認証後(OAuth2フローでcallbackとして戻ってきてから)の処理)
        handle {
            val principal = call.principal<OAuthAccessTokenResponse.OAuth2>()
            if (principal != null) {
                // セッションへ認証情報を格納
                call.sessions.set(GitHubSession(principal.accessToken))
                call.respondRedirect(application.locations.href(Index()))
            } else {
                call.respondText { "Login Error" }
            }
        }
```

---

これでOAuth2を用いた認証の一連のフローが完成しました！！
Pipeline/ApplicationCall/PipelineContext周りの動きを理解するのが最初は大変でしたが、
とてもシンプルに書けて楽しいので、もう少しKtorを色々触ってみたいと思いました。

全体のコードが気になる方は、[GitHub](https://github.com/haijima/ktor-study)をご覧ください！