諸事情あって PlayFramework における GitHub OAuth を用いた認可の実装方法について調べていたのですが、Play Framework に関する日本語記事が非常に少なく、あっても Java 向けに書かれていたりしたので、改めて自分でまとめてみました。
何か誤り等あればご指摘いただけると助かります。
作ったもの
Login をクリックすると GitHub の画面が開き、アプリの認可が求められます。認可すると
こんな感じでログインできます。
「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 を使います。具体的にどう実装していくかは play-pac4j のリポジトリの wiki を見るのが一番分かりやすいです。
また、play-pac4j-scala-demo というデモ用リポジトリがありますが、あらゆる種類の認可がごちゃ混ぜになっているので、初めて見ると結構混乱すると思います。
以下、実装の手順を順を追って説明します。
1. build.sbt, application.conf の修正
sbt new playframework/play-scala-seed.g8
でテンプレートを用意した後、設定関係のファイルを修正します。
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
demoapp.base_url = "http://localhost:9000"
demoapp.client_id = "XXXXXXXXXXXXXXXX"
demoapp.client_secret = "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
application.conf
には GitHub の認証鍵を書きますが、直書きを避けたい場合は環境変数を使うこともできます。
2. SecurityModule の実装
認証認可によってページを保護する仕組みの大元を提供するモジュールを実装します。ドキュメントはこちら
GitHub の認可を用いる場合、以下のように実装できます。
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 と LogoutControllerのwiki にそれぞれ説明を譲ります。
provideGithubClient
以降は GitHub を用いた認可の設定をしています。
SecurityModule の実装に合わせて、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など)を用いたページの実装の仕方など、アプリケーション層の部分を考えていきます。
ドキュメントはこちらやこちらなど
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
も修正しておきましょう。
# 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 テンプレートを適切に実装します。
@()
@main("NotSecret Page") {
<p>Everyone can view this page.</p>
<p><a href="/">home</a></p>
}
@()
@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
は特に難しい点はありません。
@(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()
以外のメソッドについてはドキュメントを見ていただくと良いかと思います。