Scala + PlayFramework + pac4j での GitHub ログインの実装

諸事情あって PlayFramework における GitHub OAuth を用いた認可の実装方法について調べていたのですが、Play Framework に関する日本語記事が非常に少なく、あっても Java 向けに書かれていたりしたので、改めて自分でまとめてみました。  
何か誤り等あればご指摘いただけると助かります。

# 作ったもの

以下のような画面がトップページに出ます。
![logged_out](https://user-images.githubusercontent.com/36184621/62940199-01c4b900-be0e-11e9-8924-48b4934a57f9.png)

Login をクリックすると GitHub の画面が開き、アプリの認可が求められます。認可すると

![logged_in](https://user-images.githubusercontent.com/36184621/62940212-0c7f4e00-be0e-11e9-8013-62dcc6804e8c.png)

こんな感じでログインできます。
「notsecret page」はログインしていなくてもアクセスできて、「secret page」はログインしていないとアクセスできません。

画像からも分かる通り、Herokuに https://scala-github-oauth-demo.herokuapp.com/ として push されており、元リポジトリは https://github.com/HelloRusk/scala-play-github-oauth-demo です。

# 実装方針

pac4j という Java/Scala 向けに認証認可全般を扱えるライブラリがあり、これの PlayFramework 向けの [play-pac4j](https://github.com/pac4j/play-pac4j) を使います。具体的にどう実装していくかは play-pac4j のリポジトリの [wiki](https://github.com/pac4j/play-pac4j/wiki) を見るのが一番分かりやすいです。
また、[play-pac4j-scala-demo](https://github.com/pac4j/play-pac4j-scala-demo) というデモ用リポジトリがありますが、あらゆる種類の認可がごちゃ混ぜになっているので、初めて見ると結構混乱すると思います。

以下、実装の手順を順を追って説明します。

# 1. build.sbt, application.conf の修正

`sbt new playframework/play-scala-seed.g8` でテンプレートを用意した後、設定関係のファイルを修正します。

```scala:build.sbt
name := "scala-github-oauth-demo"

version := "1.0-SNAPSHOT"

lazy val root = (project in file(".")).enablePlugins(PlayScala)

scalaVersion := "2.12.7"

val playPac4jVersion = "8.0.0"
val pac4jVersion = "3.7.0"

libraryDependencies += guice
libraryDependencies += ehcache
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.3" % Test
libraryDependencies += "org.pac4j" %% "play-pac4j" % playPac4jVersion
libraryDependencies += "org.pac4j" % "pac4j-oauth" % pac4jVersion
```

```conf:conf/application.conf
demoapp.base_url = "http://localhost:9000"
demoapp.client_id = "XXXXXXXXXXXXXXXX"
demoapp.client_secret = "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
```

`application.conf` には GitHub の認証鍵を書きますが、直書きを避けたい場合は[環境変数を使うこともできます](https://www.playframework.com/documentation/ja/2.0.8/ProductionConfiguration#%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E4%BD%BF%E3%81%86)。

# 2. SecurityModule の実装

認証認可によってページを保護する仕組みの大元を提供するモジュールを実装します。ドキュメントは[こちら](https://github.com/pac4j/play-pac4j/wiki/Security-configuration)

GitHub の認可を用いる場合、以下のように実装できます。

```scala:app/modules/SecurityModule.scala
package modules

import com.google.inject.{AbstractModule, Provides}
import org.pac4j.core.client.Clients
import org.pac4j.core.config.Config
import org.pac4j.oauth.client.GitHubClient
import org.pac4j.play.scala.{DefaultSecurityComponents, SecurityComponents}
import org.pac4j.play.{CallbackController, LogoutController}
import org.pac4j.play.store.{PlayCacheSessionStore, PlaySessionStore}
import org.pac4j.play.http.PlayHttpActionAdapter
import play.api.{Configuration, Environment}

class SecurityModule(environment: Environment, configuration: Configuration) extends AbstractModule {

  val base_url: String = configuration.get[String]("demoapp.base_url")

  override def configure(): Unit = {
    bind(classOf[PlaySessionStore]).to(classOf[PlayCacheSessionStore])
    bind(classOf[SecurityComponents]).to(classOf[DefaultSecurityComponents])

    // callback
    val callbackController = new CallbackController()
    callbackController.setDefaultUrl("/")
    bind(classOf[CallbackController]).toInstance(callbackController)

    // logout
    val logoutController = new LogoutController()
    logoutController.setDefaultUrl("/")
    bind(classOf[LogoutController]).toInstance(logoutController)
  }

  @Provides
  def provideGithubClient: GitHubClient = new GitHubClient(
    configuration.get[String]("demoapp.client_id"),
    configuration.get[String]("demoapp.client_secret")
  )

  @Provides
  def provideConfig(gitHubClient: GitHubClient): Config = {
    val clients = new Clients(base_url + "/oauth_callback", gitHubClient)
    val config = new Config(clients)
    config.setHttpActionAdapter(new PlayHttpActionAdapter())
    config
  }
}
```

Google Guice という DI フレームワークを使っています。

`configure()` は全般的な設定部分になっています。Play cache に認証情報を保持することや、デフォルトの `SecurityComponents` を持たせることを決めています。また `callbackController` と `logoutController` の設定は重要ですが、これは [CallbackControllerのwiki](https://github.com/pac4j/play-pac4j/wiki/Callback-configuration) と [LogoutControllerのwiki](https://github.com/pac4j/play-pac4j/wiki/Logout-configuration) にそれぞれ説明を譲ります。

`provideGithubClient` 以降は GitHub を用いた認可の設定をしています。

SecurityModule の実装に合わせて、application.conf に依存性注入をします。

```diff:conf/application.conf
+ play.modules.enabled += "modules.SecurityModule"

  demoapp.base_url = "http://localhost:9000"
  demoapp.client_id = "XXXXXXXXXXXXXXXX"
  demoapp.client_secret = "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
```

# 3. API の実装

SecurityModule によってコールバックとログアウトのAPIは自動的に用意されますが、ログインのAPIも必要でしょう。そのほか、認可によって得られた情報（ログインした GitHub IDなど）を用いたページの実装の仕方など、アプリケーション層の部分を考えていきます。
ドキュメントは[こちら](https://github.com/pac4j/play-pac4j/wiki/Apply-security)や[こちら](https://github.com/pac4j/play-pac4j/wiki/Get-the-authenticated-user-profiles)など

```scala:app/controllers/Application.scala
package controllers

import javax.inject.Inject
import org.pac4j.core.client.IndirectClient
import org.pac4j.core.credentials.Credentials
import org.pac4j.core.profile.{CommonProfile, ProfileManager}
import org.pac4j.play.PlayWebContext
import org.pac4j.play.scala.{Security, SecurityComponents}
import play.api.mvc.{RequestHeader, Session}

import scala.collection.JavaConverters._
import scala.compat.java8.OptionConverters._

class Application @Inject() (val controllerComponents: SecurityComponents) extends Security[CommonProfile] {
  private def getProfile(implicit request: RequestHeader): Option[CommonProfile] = {
    val webContext = new PlayWebContext(request, playSessionStore)
    val profileManager = new ProfileManager[CommonProfile](webContext)
    val profile = profileManager.get(true)
    profile.asScala
  }

  def index = Action { implicit request =>
    Ok(views.html.index(getProfile(request)))
  }

  def notSecret = Action { implicit request =>
    Ok(views.html.notsecret())
  }
  def secret = Secure("GitHubClient") { implicit request =>
    Ok(views.html.secret())
  }

  def login = Action { request =>
    val context: PlayWebContext = new PlayWebContext(request, playSessionStore)
    val client = config.getClients.findClient("GitHubClient").asInstanceOf[IndirectClient[Credentials,CommonProfile]]
    val location = client.getRedirectAction(context).getLocation
    val newSession = new Session(mapAsScalaMap(context.getJavaSession).toMap)
    Redirect(location).withSession(newSession)
  }
}
```

`getProfile()` は認可が済んでいる場合に、その認可済みユーザーのプロフィールを返す関数です。Scala のパターンマッチを使うために `Option` 型にしています。

次に、`secret` と `notSecret` の違いに注目してください。`secret` の方は `Secure("GitHubClient")` となっていますが、このように書くことで `secret()` を呼び出す API は GitHub の認可が必要であると定義できます。
`login` の実装はドキュメンテーションにあるものをほぼそのまま踏襲していますが、ここでは `"GitHubClient"` で種類を決め打ちしている部分が異なります。

`routes`も修正しておきましょう。

```diff:conf/routes
  # An example controller showing a sample home page
- GET     /                           controllers.HomeController.index
+ GET     /                           controllers.Application.index()
+
+ GET     /login                      controllers.Application.login()
+ GET     /logout                     @org.pac4j.play.LogoutController.logout()
+ GET     /oauth_callback             @org.pac4j.play.CallbackController.callback()
+
+ GET     /notsecret                  controllers.Application.notSecret()
+ GET     /secret                     controllers.Application.secret()
+ 
  # Map static resources from the /public folder to the /assets URL path
  GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)
```

# 4. views の実装

あとは、フロントの twirl テンプレートを適切に実装します。

```html:app/views/notsecret.scala.html
@()

@main("NotSecret Page") {
  <p>Everyone can view this page.</p>
  <p><a href="/">home</a></p>
}
```

```html:app/view/secret.scala.html
@()

@main("Secret Page") {
  <p>Only logged-in users can view this page.</p>
  <p><a href="/">home</a></p>
}
```

`notsecret.scala.html` と `secret.scala.html` は特に難しい点はありません。

```html:app/view/index.scala.html
@(profile: Option[org.pac4j.core.profile.CommonProfile])

@main("Scala GitHub OAuth Demo") {
  <h1>Scala GitHub OAuth Demo</h1>
  @profile match {
    case Some(profile) => {
      <p>You are currently logged in as @profile.getUsername()</p>
      <p><a href="/logout">Logout</a></p>
    }
    case None => {
      <p><a href="/login">Login</a></p>
    }
  }
  <br/>
  <p><a href="/notsecret">notsecret page</a></p>
  <p><a href="/secret">secret page</a></p>
}
```

`index.scala.html` の方ですが、認可を済ませていない場合といる場合で表示を変えるために分岐させています。  
まず、引数の`profile`は認可によって得られたプロフィールの情報 `org.pac4j.core.profile.CommonProfile` を `Option` で包んだものです。
認可が済んでいる場合は中身にプロフィール情報が含まれていて、認可が済んでいない場合は `None` です。  
ちなみに `Common` というのはどういうことかというと、GitHub の他にも Twitter認可、Facebook認可、Google認可...など様々な認可がありますが、それらに共通してあるようなユーザープロフィール情報を持っているということです。`.getUsername()` 以外のメソッドについては[ドキュメント](http://www.pac4j.org/apidocs/pac4j/2.2.0/org/pac4j/core/profile/CommonProfile.html)を見ていただくと良いかと思います。