React + Playの認証/認可方法を調べてみた


# はじめに

ReactとPlayで認証はどのようにやればいいかと思い調べてみました。
認証の基本的な流れについては[こちら](https://github.com/joshgeller/react-redux-jwt-auth-example)を参考にしました。認証サービスとしてAPIのエンドポイントを用意して、Reactアプリケーションから認証する方法です。
認証にはJWTを使用します。またJWT内にロールを入れることで認可機能を追加します。

WebアプリケーションフレームワークにはPlayを使用しています。
認証と認可のライブラリには[Silhouette](https://github.com/mohiva/play-silhouette)を使用します。一からだと分かりづらいので[play-silhouette-seed](https://github.com/mohiva/play-silhouette-seed)のテンプレートを使用しています。
これを少し変えてJWT認証をしてみたいと思います。

Silhouetteの概要については[こちら](http://www.silhouette.rocks/docs)を参照しました。

使用したバージョンです。
- Play 2.5.4
- Silette 4.0

# 初期設定

Silhouetteのエンドポイントで使用する _Identity_ と _Authenticator_ を指定する必要があります。
`com.mohiva.play.silhouette.api.Identity`は名前やメールアドレスなどの認証されるユーザーについての情報を持っています。
`com.mohiva.play.silhouette.api.Authenticator`はOAuthやidとpasswordを使った認証などの認証方法を持っています。

これを`utils.auth.DefaultEnv`で指定します。
JWTを使うのでAuthenticatorは`JWTAuthenticator`を使用します。

```scala:utils.auth.Env
package utils.auth

import com.mohiva.play.silhouette.api.Env
import com.mohiva.play.silhouette.impl.authenticators.{ CookieAuthenticator, JWTAuthenticator }
import models.User

trait DefaultEnv extends Env {
  type I = User
  type A = JWTAuthenticator
}
```

## CSRFとCORSの設定

ReactとAPIは異なるオリジンになるのでCORSの設定を追加します。
CSRFについてもチェックをバイパスするように設定を追加します。

https://www.playframework.com/documentation/2.5.x/ScalaCsrf
https://www.playframework.com/documentation/2.5.x/CorsFilter#Enabling-the-CORS-filter

## JWTAuthenticatorの設定

[設定例](http://www.silhouette.rocks/v4.0/docs/config-authenticators#section-jwtauthenticator)を参考に`conf/silhouette.conf`に設定を書き換えます。

```yaml:silhouette.conf
silhouette {

  authenticator.fieldName = "X-Auth-Token"
  authenticator.requestParts = ["headers"]
  authenticator.issuerClaim = "play-react-silhouette"
  authenticator.encryptSubject = true
  authenticator.authenticatorExpiry = 12 hours
  authenticator.sharedSecret = "changeme"
```

# 認証の流れ

APIからJWTを取得して、それを使い回すことで認証されたAPIにアクセスできるようにします。

1. サインアップ (認証に必要なメールアドレスやユーザー名を登録します)
2. アクティベート (メールアドレスを有効化します)
3. サインイン (JWTを取得します)
4. JWTを使った認証

## サインアップ

サインアップに以下の4つの情報をJSONとしてPOST bodyに入れて送ります
firstName, lastName, fullName, email

ほとんど変えていないのですが、レスポンスにHTMLは必要ないのでHTTPステータスだけ返すようにしています。

```scala:SignUpController.scala
def submit = silhouette.UnsecuredAction.async { implicit request =>
    SignUpForm.form.bindFromRequest.fold(
      form => Future.successful(BadRequest),
      data => {
        val result = Redirect(routes.SignUpController.view()).flashing("info" -> Messages("sign.up.email.sent", data.email))
        val loginInfo = LoginInfo(CredentialsProvider.ID, data.email)
        userService.retrieve(loginInfo).flatMap {
          case Some(user) =>
            val url = routes.SignInController.view().absoluteURL()
            mailerClient.send(Email(
              subject = Messages("email.already.signed.up.subject"),
              from = Messages("email.from"),
              to = Seq(data.email),
              bodyText = Some(views.txt.emails.alreadySignedUp(user, url).body),
              bodyHtml = Some(views.html.emails.alreadySignedUp(user, url).body)
            ))

            Future.successful(result)
          case None =>
            val authInfo = passwordHasherRegistry.current.hash(data.password)
            val user = User(
              userID = UUID.randomUUID(),
              loginInfo = loginInfo,
              firstName = Some(data.firstName),
              lastName = Some(data.lastName),
              fullName = Some(data.firstName + " " + data.lastName),
              email = Some(data.email),
              avatarURL = None,
              activated = false
            )
            for {
              avatar <- avatarService.retrieveURL(data.email)
              user <- userService.save(user.copy(avatarURL = avatar))
              authInfo <- authInfoRepository.add(loginInfo, authInfo)
              authToken <- authTokenService.create(user.userID)
            } yield {
              val url = routes.ActivateAccountController.activate(authToken.id).absoluteURL()
              mailerClient.send(Email(
                subject = Messages("email.sign.up.subject"),
                from = Messages("email.from"),
                to = Seq(data.email),
                bodyText = Some(views.txt.emails.signUp(user, url).body),
                bodyHtml = Some(views.html.emails.signUp(user, url).body)
              ))

              silhouette.env.eventBus.publish(SignUpEvent(user, request))
              Ok
            }
        }
      }
    )
  }
```

## アクティベート

そのままだと登録したemailアドレス宛にemailのアクティベート用のリンクを送らなければならず動作を確認しづらいので、標準出力にメールの内容が表示されるようにします。

```application.conf
play.mailer {
  mock = true
  host = localhost
}
```

POSTすると標準出力にメール内容が出力されると思います。
リンクをGETするとアクティベートします。

`ActivateAccountController`でアクティベート処理をしています。

```scala:ActivateAccountController.scala
def activate(token: UUID) = silhouette.UnsecuredAction.async { implicit request =>
    authTokenService.validate(token).flatMap {
      case Some(authToken) => userService.retrieve(authToken.userID).flatMap {
        case Some(user) if user.loginInfo.providerID == CredentialsProvider.ID =>
          userService.save(user.copy(activated = true)).map { _ =>
            Ok
          }
        case _ => 
          Future.successful(BadRequest)
      }
      case None => 
        Future.successful(BadRequest)
    }
  }
```

アクティベートされたことを`models/User`クラスに保存します。
`models/User`の`activated: Boolean`で判定しています。
アクティベートに成功した場合はOk, 失敗た場合はBadRequestを返すようにしています。
`activate`はHTMLをリクエスト元に返す必要がないので、HTTPステータスだけ返しています。

## サインイン
一意なIDとパスワードを渡して認証をします。ここではIDとしてemailを使用しています。
React ---(email, password)---> API

サインインのコントローラーはHTMLをリクエスト元に返す必要はないので、HTTPステータスだけ返しています。

```scala:app/controllers/SignInController.scala
def submit = silhouette.UnsecuredAction.async { implicit request =>
    SignInForm.form.bindFromRequest.fold(
      form => Future.successful(BadRequest(views.html.signIn(form))),
      data => {
        val credentials = Credentials(data.email, data.password)
        credentialsProvider.authenticate(credentials).flatMap { loginInfo =>
          val result = Redirect(routes.ApplicationController.index())
          userService.retrieve(loginInfo).flatMap {
            case Some(user) if !user.activated =>
              Future.successful(BadRequest)
            case Some(user) =>
              val c = configuration.underlying
              silhouette.env.authenticatorService.create(loginInfo).map {
                case authenticator if data.rememberMe =>
                  authenticator.copy(
                    expirationDateTime = clock.now + c.as[FiniteDuration]("silhouette.authenticator.rememberMe.authenticatorExpiry"),
                    idleTimeout = c.getAs[FiniteDuration]("silhouette.authenticator.rememberMe.authenticatorIdleTimeout"),
                    customClaims = Some(JsObject(Seq("role" -> JsString("operator")))) //ロールを設定する
                  )
                case authenticator => authenticator
              }.flatMap { authenticator =>
                silhouette.env.eventBus.publish(LoginEvent(user, request))
                silhouette.env.authenticatorService.init(authenticator).flatMap { v =>
                  silhouette.env.authenticatorService.embed(v, Ok)
                }
              }
            case None => Future.failed(new IdentityNotFoundException("Couldn't find user"))
          }
        }.recover {
          case e: ProviderException =>
            BadRequest
        }
      }
    )
  }
```

JWTに認可用のロールを入れたいので、JWTの _Private claims_ に対して値を入れてやります。
`Authenticator`のインスタンスを作成する際に、`customClaims`の引数に値を渡してやります。

`customClaims = Some(JsObject(Seq("role" -> JsString("admin"))))`
この場合は _{"role": "admin"}_ という値が入るようになります。
この _role_ を使ってAPIがリクエストされた時に認可の判定をします。

### 認可

`com.mohiva.play.silhouette.api.Authorization`を継承した`WithProvider`に`isAuthorized`の実装を与えることで認可の判定をしています。

```scala:utils.auth.WithProvider
case class WithProvider[A <: Authenticator](provider: String) extends Authorization[User, A] {
  override def isAuthorized[B](user: User, authenticator: A)(implicit request: Request[B]): Future[Boolean] = {
  	val authorized = ??? // 何か認可の判定をします
    Future.successful(authorized)
  }
}
```

認証と同じようにリクエストが来てコントローラーのアクションがリクエストを受け取ったタイミングで認可の判定をしないといけません。`silhouette.SecuredAction`に`WithProvider`を渡すことで、認可を入れています。

```scala
def index = silhouette.SecuredAction(WithProvider[DefaultEnv#A](CredentialsProvider.ID)).async { implicit request =>
    Future.successful(Ok)
  }
```

### JWTトークン取得
ログインに成功するとAPIから`X-Auth-Token`ヘッダーにトークンの値が入りレスポンスとして返ってきます。
React <---(JWT)--- API

React側ではWeb Strageを利用してトークンを保存します。

## JWTを使った認証

サインイン後はJWTを使用して認証をします。
同じように`X-Auth-Token`ヘッダーにWeb Strageに保存しておいたJWTをセットします。
これで、`silhouette.SecuredAction`で認証と認可をすることができます。

React -(JWT)-> API

### ErrorHandler

認証エラーや認可エラーの時に返すレスポンスが必要です。
`SecuredErrorHandler`と`UnsecuredErrorHandler`を実装する必要があります。
`utils.auth.CustomSecuredErrorHandler`と`utils.auth.CustomUnsecuredErrorHandler`に`onNotAuthorized`などのエラー関数をオーバーライドします。

```scala:utils.auth.CustomSecuredErrorHandler
override def onNotAuthorized(implicit request: RequestHeader) = {
    Future.successful(Forbidden)
  }
```

上記のように認証されていなければ _Forbidden_ を返すようにしています。

# その他

## CORS

デフォルトだとCORSの設定でブラウザからアクセスできるレスポンスヘッダーは何もないので、[ドキュメント](https://www.playframework.com/documentation/2.5.x/CorsFilter)を参考に _application.conf_ にplay.filters.cors.exposedHeadersの設定を加えます。

```scala:application.conf
play.filters.cors.exposedHeaders = ["x-auth-token"]
```

## デバッグ

IntelliJ IDEAでデバッグする時JVMをforkしてしまうとデバッグできないので、forkしないようにしておきます。

```scala:build.sbt
fork in run := false
```

## JWTの有効性

`SecuredAciton`から`RequestHandler`が呼ばれますが、そこで認証のチェックをしています。

```scala:RequestHandler.scala
case Some(a) if a.isValid  => environment.identityService.retrieve(a.loginInfo).map(i => Some(Left(a)) -> i)
```
この部分でLoginInfoを使って _Identity_ を探しています、もしあれば認証が通ります。
JWTが正しいだけではなく、 _Identity_ が存在するかも見ています。なので _Identity_ を永続化しておく必要があります。そうしないとアプリケーションを再起動した時に有効なJWTでも認証が通らなくなってしまいます。

## 永続化するエンティティ

外部(DBなど)に保存できるのは
1. `Identity`を継承した`User`クラス
2. `PasswordInfo`
3. `JWTAuthenticator`
の3つがあります。 

play-silhouette-seedでは全てメモリ何保存するようにDAOが実装されています。
なので、実際にはDBなどに永続化する必要があります。

# おわりに

Silhouetteを使ってReactの認証/認可周りを簡単に実装できることがわかりました。
パスワード忘れた時のリセット周りだとか、自前で実装しなくていいので便利ですね！