チュートリアル: play2-authでPlayに独自の認証を実装する

PlayFrameworkで独自の認証をplay2-authで実装するための自分向けチュートリアルです。独自というのは、よくありそうなMySQLなどのRDBMSを使う認証ではなく、社内にRESTful APIで認証を行うインターフェイスを備えている認証サーバがあり、そのプロトコルを使った認証を実装するということです。

## ゴール

このチュートリアルが目指すゴールは下の目標を全て達成することです。

1. play2-authを使って
2. Ajax用のエンドポイント`/api/auth`を作成し、POSTリクエストを送り、認証できるようになる
3. Ajax用のエンドポイント`/api/user`を作成し、認可されたセッションで自分のアカウント情報が見れるようになる

なおこのチュートリアルの成果物は[suin/php-scala-migration-example](https://github.com/suin/php-scala-migration-example/tree/master/new-ui)にあげてあります。ご活用ください。

## ベース


今回は次のコマンドで、`play-scala`テンプレートから作ったPlayプロジェクトをベースに実装していきたいと思います。

```
activator new new-ui play-scala
```

## build.sbtのセットアップ

build.sbtに`play2-auth`を追加します。

```scala:build.sbt
name := """new-ui"""

version := "1.0-SNAPSHOT"

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

scalaVersion := "2.11.6"

libraryDependencies ++= Seq(
  jdbc,
  anorm,
  cache,
  ws,
  "jp.t2v" %% "play2-auth"      % "0.13.2",
  "jp.t2v" %% "play2-auth-test" % "0.13.2" % "test"
)
```

activatorを起動して、runを実行するとライブラリがダウンロードされれば、build.sbtの設定はOKです。

```
activator
> run
```

Ctrl + D → Ctrl + Cの順でキーを押下して、activatorを停止します。

## 認証の設定(AuthConfig)を実装する

ここからScalaコードを書きたいと思います。僕は、IntelliJを使うので、IDE用のプロジェクトファイルを生成しておきます。

```
activator
> gen-idea
```

ライブラリがドメインオブジェクトのシグネチャを決めてしまうライブラリと違い、play2-authでは自分が定義したドメインオブジェクトを認証に組み込むことができるのがいいところです。なので、はじめにアカウントまわりのドメインオブジェクトを定義してしまいます。

最低限、ユーザを表現するクラスと、ロールを表現するクラスが必要になるので、ここでは`User`と`Role`を定義します。なお、クラス名は自由です。ここでの`User`は`Account`でもいいですし、`Role`は`UserType`でもかまいません。

```scala:app/models/account/package.scala
package models

package object account {

  case class User(
                   id: String,
                   username: String,
                   role: Role)


  sealed trait Role

  object Role {
    case object Administrator extends Role
    case object NormalUser extends Role
  }
}

```

また、認証サーバとやりとりして、`User`オブジェクトをひっぱてくるサービスを作っておきます。ここでは`AccountService`という名前にします。

```scala:app/services/AccountService.scala
package services

import models.account.User

// 本当は認証サーバにRESTで問い合わせる
object AccountService {
  private val users = List[User](
    User("6E7D8ACD-C36E-4ACC-87B7-5EC35CBA1423", "admin", models.account.Role.Administrator),
    User("D4EAF402-54CC-469A-AACB-66EF0A0CB0AF", "alice", models.account.Role.Administrator)
  )

  def userOfId(id: String): Option[User] = {
    users.find(_.id == id)
  }

  def authenticate(username: String, password: String): Option[User] = {
    users.find(_.username == username)
  }
}

```

自前の認証を作るためにtraitを1つ作成します。ここでは`ProprietaryAuthConfig.scala`という名前にします。このtraitはjp.t2v.lab.play2.auth.AuthConfigを実装することになるので、継承しておきます。




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

import jp.t2v.lab.play2.auth._

trait ProprietaryAuthConfig extends AuthConfig {

}

```

続いて、`ProprietaryAuthConfig`を実装していきます。実装が必要なメンバは、下のコードで示すとおりです。

最後の`authorizationFailed`については非推奨のものですが、とりあえず、例外を投げるようにしておけばいいようです。将来的にこのメソッドは実装しなくてもよくなるかと思います。

また、ここで注意しておきたいのは`override val idTag: ClassTag[Id]`には`implicit`をつけてはいけないということです。僕の場合は、IDEのコード生成で勝手に`implicit`がついてしまい、無限ループ？になってしまいました… :sweat: 

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

import jp.t2v.lab.play2.auth._
import play.api.mvc.{Result, RequestHeader}
import play.api.mvc.Results.{Redirect, Forbidden, Unauthorized}
import scala.concurrent.{Future, ExecutionContext}
import scala.reflect.{ClassTag,classTag}
import models.account.Role._
import services.AccountService

trait ProprietaryAuthConfig extends AuthConfig {

  /**
   * ユーザを識別するための型。
   * `String`, `Int`, `Long` など。
   */
  override type Id = String

  /**
   * ユーザを表現する型。modelなどに定義したclassの型を指定する。
   */
  override type User = models.account.User

  /**
   * 認可のために使う権限を表現した型。
   */
  override type Authority = models.account.Role

  /**
   * A `ClassTag` is used to retrieve an id from the Cache API.
   * Use something like this:
   */
  override val idTag: ClassTag[Id] = classTag[Id]

  /**
   * セッションがタイムアウトするまでの時間(秒)
   */
  override def sessionTimeoutInSeconds: Int = 3600 // 1時間

  /**
   * `Id`からユーザを探す方法。
   */
  override def resolveUser(id: Id)(implicit context: ExecutionContext): Future[Option[User]] = {
    Future.successful(AccountService.userOfId(id))
  }

  /**
   * ログインできたらどうするか？
   */
  override def loginSucceeded(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Redirect(routes.Application.index))
  }

  /**
   * ログアウトしたらどうするか？
   */
  override def logoutSucceeded(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Redirect(routes.Application.index))
  }

  /**
   * 認証に失敗したらどうするか？
   */
  override def authenticationFailed(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Unauthorized("Bad credentials"))
  }

  /**
   * 権限がないアクセスはどうするか？
   */
  override def authorizationFailed(request: RequestHeader, user: User, authority: Option[Authority])(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Forbidden("No permission"))
  }

  /**
   * ユーザが権限を持っているかを判定する関数。
   */
  override def authorize(user: User, authority: Authority)(implicit context: ExecutionContext): Future[Boolean] = Future.successful {
    (user.role, authority) match {
      case (Administrator, _)       => true // AdminならどんなActionでも全権限を開放
      case (NormalUser, NormalUser) => true // ユーザがNormalUserで、ActionがNormalUserなら権限あり。もしActionがAdminだけなら権限なしになる。
      case _                        => false
    }
  }

  override def authorizationFailed(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = throw new AssertionError("don't use")
}

```

ひとまずコンパイルしてみます。ここで、コンパイルエラーがでなければOKです。

```
> compile
```

## ログインのAPIを実装する(Authentication)

それではログインのAPIを作っていきます。このセクションは認証(Authentication)を実装するチュートリアルになります。

その前に、ここからHTTPでレスポンスを確認しながら開発していきたいので、HTTPサーバを起動します。

```
> run
```

http://localhost:9000/ を開いてトップページが表示されれば、起動はOKです。


![Welcome_to_Play.png](https://qiita-image-store.s3.amazonaws.com/0/889/84164cb2-61fb-01fa-b38a-12eecb8f72be.png)


認証はAjaxでやりたいと思うので、Ajax用のエンドポイントを作ります。ここでは、エンドポイントは下のURLに決めます。

```
POST http://localhost:9000/api/auth
```

Actionを作ります。ファイル名は、AuthApi.scalaとします。

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

import play.api.mvc._

object AuthApi extends Controller {

  def auth = Action {
    Ok("Successfully authenticated!")
  }

}

```

ルーティング設定もしておきます。


```nginx:conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.Application.index

# Auth APIs
POST    /api/auth                   controllers.AuthApi.auth

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.at(path="/public", file)
```

Playのサーバが立ち上がったままだと思うので、試しにPOSTリクエストを出してみます。リクエストを出すにはHTTPまわりのデバッグに便利な[httpie](https://github.com/jakubroztocil/httpie)を使っていきます。


```console
$ http -v  POST localhost:9000/api/auth
POST /api/auth HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 0
Host: localhost:9000
User-Agent: HTTPie/0.8.0



HTTP/1.1 200 OK
Content-Length: 27
Content-Type: text/plain; charset=utf-8

Successfully authenticated!
```

レスポンスが200 OKとなっていればルーティングの設定までがうまくできています。

ではログインの処理を実装していきます。先ほどの`AuthApi`コントローラで`jp.t2v.lab.play2.auth.LoginLogout`と`ProprietaryAuthConfig`を継承します。加えて`auth`アクションを実装していきます。

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

import play.api.mvc._
import play.api.libs.json._
import jp.t2v.lab.play2.auth.LoginLogout
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import services.AccountService

object AuthApi extends Controller with LoginLogout with ProprietaryAuthConfig {

  // POSTで渡ってくるデータの形式
  case class AuthData(username: String, password: String)

  // JSONをパースできるようにするための宣言
  implicit val authDataReads = Json.reads[AuthData]

  def auth = Action.async(BodyParsers.parse.json) { implicit request =>
    request.body.validate[AuthData].fold(
      errors => {
        Future.successful(BadRequest(Json.obj("message" -> JsError.toFlatJson(errors))))
      },
      data => {
        AccountService.authenticate(data.username, data.password) match {
          case None => Future.successful(Unauthorized(Json.obj("message" -> "authentication failed")))
          case Some(user) => gotoLoginSucceeded(user.id)
        }
      }
    )
  }
}

```

ここまで実装したら、`POST /api/auth`にて認証できるか試してみます。


```console
$ http -v POST localhost:9000/api/auth username=alice password=secret
POST /api/auth HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 43
Content-Type: application/json; charset=utf-8
Host: localhost:9000
User-Agent: HTTPie/0.8.0

{
    "password": "secret",
    "username": "alice"
}

HTTP/1.1 303 See Other
Content-Length: 0
Location: /
Set-Cookie: PLAY2AUTH_SESS_ID=f25da3108fa744895a79b135bb9b75734dfb18fe_5hdnvje'cvef)5e'f964b7i~l)6)x5hmex1e~w.5v_kdv~2hwy)qc55nwzs4wf6; Max-Age=3600; Expires=Tue, 26 May 2015 08:48:46 GMT; Path=/; HTTPOnly
```

ステータスコードが303で、`Set-Cookie`ヘッダーが出ていれば成功です。

ついでに認証に失敗する場合も確認します。

```console
$ http -v POST localhost:9000/api/auth username=bob password=secret
POST /api/auth HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 41
Content-Type: application/json; charset=utf-8
Host: localhost:9000
User-Agent: HTTPie/0.8.0

{
    "password": "secret",
    "username": "bob"
}

HTTP/1.1 401 Unauthorized
Content-Length: 35
Content-Type: application/json; charset=utf-8

{
    "message": "authentication failed"
}
```

認証に失敗したら、401になるように実装していたので、期待通り401になるはずです。

## アカウント情報を取得できるAPIを追加する(Authorization)

認証ができるようになったので、今度はログイン中のユーザが、自分のアカウント情報をAPI経由で取得できるようにしたいと思います。ここからは、認可(Authorization)のチュートリアルになります。

APIのエンドポイントは下記のURLにします。

```
GET /api/user
```

さっそくControllerを作っていきます。ファイル名は`UserApi.scala`とします。

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

import play.api.mvc._

object UserApi extends Controller {
  def user = Action {
    Ok("your account info will be shown here")
  }
}

```

ルーティング設定も忘れずに追加します。

```nginx:conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.Application.index

# Auth APIs
POST    /api/auth                   controllers.AuthApi.auth

# User APIs
GET     /api/user                   controllers.UserApi.user

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.at(path="/public", file)
```

ひとまずルーティングだけでもうまくできているか確認してみます。200でレスポンスが来ればOKです。

```console
$ http localhost:9000/api/user
HTTP/1.1 200 OK
Content-Length: 36
Content-Type: text/plain; charset=utf-8

your account info will be shown here
```

では、この`UserApi`コントローラの`user`アクションを認可されたユーザだけが見れるようにしてみます。認可の機能を使うには、`jp.t2v.lab.play2.auth.AuthElement`と、自作した`ProprietaryAuthConfig`をコントローラにmix-inします。

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

import jp.t2v.lab.play2.auth.AuthElement
import models.account.Role.NormalUser
import play.api.mvc._
import play.api.libs.json._

object UserApi extends Controller with AuthElement with ProprietaryAuthConfig {
  def user = StackAction(AuthorityKey -> NormalUser) { implicit request =>
    val user = loggedIn
    Ok(Json.obj("id" -> user.id, "username" -> user.username))
  }
}
```

この`StackAction`メソッドは、`StackAction(AuthorityKey -> ロール名)`という形でアクセス制限をすることができます。`loggedIn`メソッドは、現在セッションのユーザオブジェクトが返ってきます。

ここまで実装できた、認可のテストをしてみます。httpieは`--session=name`オプションで任意のnameのセッションを保持することができます。次のコマンドでログインします。

```console
$ http --session=alice POST localhost:9000/api/auth username=alice password=secret
HTTP/1.1 303 See Other
Content-Length: 0
Location: /
Set-Cookie: PLAY2AUTH_SESS_ID=af8da1226ff979822d26ed9025c0b8b198fcf282*vl'4jrg!rk_77*cdxu174m('us~*wgu9rsx4o(ik9'e39hedjvs9ntyh2(fvsac; Max-Age=3600; Expires=Tue, 26 May 2015 09:29:53 GMT; Path=/; HTTPOnly
```

次に、セッションを保ったまま`/api/user`にアクセスしてみます。

```console
$ http -v --session=alice localhost:9000/api/user
GET /api/user HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Cookie: PLAY2AUTH_SESS_ID=af8da1226ff979822d26ed9025c0b8b198fcf282*vl'4jrg!rk_77*cdxu174m('us~*wgu9rsx4o(ik9'e39hedjvs9ntyh2(fvsac
Host: localhost:9000
User-Agent: HTTPie/0.8.0



HTTP/1.1 200 OK
Content-Length: 64
Content-Type: application/json; charset=utf-8
Set-Cookie: PLAY2AUTH_SESS_ID=af8da1226ff979822d26ed9025c0b8b198fcf282*vl'4jrg!rk_77*cdxu174m('us~*wgu9rsx4o(ik9'e39hedjvs9ntyh2(fvsac; Max-Age=3600; Expires=Tue, 26 May 2015 09:32:19 GMT; Path=/; HTTPOnly

{
    "id": "D4EAF402-54CC-469A-AACB-66EF0A0CB0AF",
    "username": "alice"
}

```

レスポンスが200で、`alice`のアカウント情報が出ていれば成功です。

加えて、ログインしていないユーザでリクエストを出してみましょう。

```console
$ http -v localhost:9000/api/user
GET /api/user HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:9000
User-Agent: HTTPie/0.8.0



HTTP/1.1 401 Unauthorized
Content-Length: 15
Content-Type: text/plain; charset=utf-8

Bad credentials
```

レスポンスが401になっていれば成功です。このレスポンスは`ProprietaryAuthConfig.authenticationFailed`で設定した内容になっているはずです。


## つづく

つづきとしてUIを実装してAjaxでログイン・アカウント情報の表示ができるようにしてみたいと思います。