PlayFramework

Play Framework 2.6でコントローラーのアクションごとに認証の必要の有無を設定する

前置き

Play Framework のドキュメントを参照していて思わぬ袋小路に入ってしまったことがあったので、ここにメモしておきます。

途中の試行錯誤が長いので、今とにかくやり方だけを知りたい場合は後ろほうの「うまくいったアプローチ」を参照してください。

この文章は、Play FrameworkのHow toというよりは、Play Framework関連のドキュメントをたどって迷いに迷った記録としての意味合いが強いです。

目標

やりたかったのは掲題のとおりで、コントローラークラスの中で

app/controllers/HomeController.scala
  // 誰でもアクセスできるアクション
  def index = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.index())
  }

  // ログイン済みのユーザーだけがアクセスできるようにしたいアクション
  def detail = authenticatedAction { implicit request: Request[AnyContent] =>
    Ok(views.html.detail())
  }

というような書き方をして、indexアクションについては誰でもアクセスできるけれども、detailアクションについてはログインしたユーザーでないとアクセスできないようにすることでした。

最初のサンプル

まずはユーザー認証なしで、ベースとなるサンプルアプリケーションを作成してみます。
(テスト用に簡易ログイン・ログアウトのアクションも追加してあります)

conf/routes
GET     /index                      controllers.HomeController.index
GET     /detail                     controllers.HomeController.detail
app/controllers/HomeController.scala
class HomeController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {

  // 誰でもアクセスできるアクション
  def index = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.index())
  }

  // ログイン済みのユーザーだけがアクセスできるようにしたいアクション
  def detail = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.detail())
  }

  // 簡易ログインアクション
  def login = Action { implicit request: Request[AnyContent] =>
    Redirect("/index").withSession("username" -> "someone")
  }

  // 簡易ログアウトアクション
  def logout = Action { implicit request: Request[AnyContent] =>
    Redirect("/index").withNewSession
  }
}

app/views/index.scala.html
@()

@main("認証デモ") {
  <h1>認証デモ</h1>
  <p><a href="/detail">詳細表示(ログインが必要)</a></p>
  <p><a href="login">ログイン</a></p>
  <p><a href="logout">ログアウト</a></p>
}
app/views/detail.scala.html
@()

@main("認証が必要なページ") {
<h1>認証が必要なページ</h1>
<p>ログインユーザーだけが見られます</p>
<p><a href="/index">戻る</a></p>
}

行き詰まったアプローチ

公式ドキュメントに「認証」の項目があるので参照してみました。
https://www.playframework.com/documentation/2.6.x/ScalaActionsComposition#Authentication

One of the most common use cases for action functions is authentication. We can easily implement our own authentication action transformer that determines the user from the original request and adds it to a new UserRequest. Note that this is also an ActionBuilder because it takes a simple Request as input:

import play.api.mvc._

class UserRequest[A](val username: Option[String], request: Request[A]) extends WrappedRequest[A](request)

class UserAction @Inject()(val parser: BodyParsers.Default)(implicit val executionContext: ExecutionContext)
  extends ActionBuilder[UserRequest, AnyContent] with ActionTransformer[Request, UserRequest] {
  def transform[A](request: Request[A]) = Future.successful {
    new UserRequest(request.session.get("username"), request)
  }
}

日本語訳がほしい人はV2.4のドキュメントをどうぞ。内容は同じです。
https://www.playframework.com/documentation/ja/2.4.x/ScalaActionsComposition#%E8%AA%8D%E8%A8%BC

アクション関数の最も一般的な使用例のひとつが認証です。元のリクエストからユーザーを判断し、それを新しい UserRequest に追加する独自の認証アクションを簡単に実装することができます。シンプルな Request を入力として受け取るので、これもActionBuilderです。

Play Frameworkのユーザーは、これだけの説明文とサンプルコードだけでアプリケーションに認証機能を追加できないといけないのですね...:confounded:

そのまんまやってみました。
UserActionクラス(サンプルコードのまま)を定義して、コントローラークラスにInjectします。

app/controllers/HomeController.scala
class UserRequest[A](val username: Option[String], request: Request[A]) extends WrappedRequest[A](request)

class UserAction @Inject()(val parser: BodyParsers.Default)(implicit val executionContext: ExecutionContext)
  extends ActionBuilder[UserRequest, AnyContent] with ActionTransformer[Request, UserRequest] {
  def transform[A](request: Request[A]) = Future.successful {
    new UserRequest(request.session.get("username"), request)
  }
}

class HomeController @Inject()(userAction: UserAction)(cc: ControllerComponents) extends AbstractController(cc) {
...
  def detail = userAction { implicit request: UserRequest[AnyContent] =>
    Ok(views.html.detail())
  }
...
}

しかし、これだけだとログインしていてもいなくてもdetailのページが見えてしまいます。

正しくはアクション内でいちいちUserRequestの中身をチェックして

  def detail = userAction { implicit request: UserRequest[AnyContent] =>
    // UserRequestの中身をチェックして、usernameがNoneなら未認証
    request.username match {
      case Some(username) => Ok(views.html.show())
      case None => Unauthorized
    }
  }

と書かないといけないです。

このように必要なアクションすべてにいちいちmatch式を追加するのは当初想定していた書き方とは異なるので、別の方法を模索することにしました。

一応の解決に見えたアプローチ

公式ドキュメントには続きがありました。

Play also provides a built in authentication action builder. Information on this and how to use it can be found here.

V2.4の日本語訳もあります(リンク先が微妙に異なっているのがミソですが)。

Play はまた、組み込みの認証アクションビルダーを提供しています。 詳細と使用方法についてはこちらを参照してください。

V2.6で上のリンクをたどると、AuthenticatedBuilderのAPIドキュメントに飛ばされるのですが、そこにはValue Membersの紹介があるだけで、サンプルコードなどは見当たりません。
Play Frameworkのユーザーはサンプルコードを見なくても、関数名と引数リスト、戻り値の型だけでアプリケーションに認証機能を追加できないといけないのですね...:confounded:

それはともかくこれがplay.api.mvc.Securityオブジェクトの一部だということが分かりますので、SecurityオブジェクトのAPIドキュメントを参照してみます。
すると色々出てきました。

def Authenticated[A](userinfo: (RequestHeader)  Option[A], onUnauthorized: (RequestHeader)  Result)(action: (A)  EssentialAction): EssentialAction

Wraps another action, allowing only authenticated HTTP requests.

これはいい感じです。サンプルコードもあります。

trait AuthenticatedAction {
  def username(request: RequestHeader) = request.session.get("username")
  def onUnauthorized(request: RequestHeader) = Unauthorized
  def isAuthenticated(f: => String => Request[AnyContent] => Result) = {
    Authenticated(username, onUnauthorized) { user =>
      Action(request => f(user)(request))
    }
  }
}

というtraitを作成して

  def detail = isAuthenticated { ... }

とすればいいようです。

コントローラーはこんな感じになります。

app/controllers/HomeController.scala
class HomeController @Inject()(cc: ControllerComponents) extends AbstractController(cc) with AuthenticatedAction {
  ...
  def detail = isAuthenticated { username => implicit request: Request[AnyContent] =>
    Ok(views.html.detail())
  }
  ...
}

ログインせずに /detail にアクセスすると、ステータスコード401(unauthorized)が返ってくるようになりました。

これで目標達成と思われたのですが...

新しい問題

もしも認証を追加する前のdetailアクションに

  def detail = Action.async { implicit request: Request[AnyContent] =>
    Future{
      // DBアクセスなど時間のかかる処理
      Ok(views.html.show())
    }
  }

みたいに".async"がついていたらどうしましょうか?

  def detail = isAuthenticated.async { ...

ではエラーになってコンパイルできません。

うまくいったアプローチ

公式ドキュメントをたどっても手詰まりになったので、ここで参考サイトをがらっと変えてみます。

Play Framework の公式サンプルを参考にしてみます。
https://github.com/playframework/play-scala-secure-session-example というのがあります。
ここで play-scala-secure-session-example/app/controllers/package.scala を見ると、ActionBuilderを継承する方法を採用していることがわかります。

これを真似て単純化した

@Singleton
class AuthenticatedAction @Inject()(playBodyParsers: PlayBodyParsers, messagesApi: MessagesApi)(implicit val executionContext: ExecutionContext)
  extends ActionBuilder[MessagesRequest, AnyContent] {

  override def parser: BodyParser[AnyContent] = playBodyParsers.anyContent

  override def invokeBlock[A](request: Request[A], block: MessagesRequest[A] => Future[Result]): Future[Result] = {
    request.session.get("username") match {
      case Some(username) => block(new MessagesRequest(request, messagesApi))
      case None => Future(Unauthorized)
    }
  }
}

というクラスを作って、コントローラークラスにInjectします。
こんな感じです。今回は".async"がついても意図した通りの動作になります。

app/controllers/HomeController.scala
@Singleton
class HomeController @Inject()(authenticatedAction: AuthenticatedAction)(cc: ControllerComponents)(implicit ec: ExecutionContext) extends AbstractController(cc) {
  ...
  def detail = authenticatedAction.async { implicit request: Request[AnyContent] =>
    Future{
      // DBアクセスなど時間のかかる処理
      Ok(views.html.show())
    }
  }
  ...
}

これでようやく当初の目標が達成されました。

コントローラー内でユーザー名などの情報にアクセスしたい場合は、上記のplay-scala-secure-session-exampleを参考に作り込んでいくことになります。が、今それをやると話が長くなりすぎるので、今日のところはここまでとさせてください。