Scala
React
ScalaDay 14

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

More than 1 year has passed since last update.

はじめに

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

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

Silhouetteの概要についてはこちらを参照しました。

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

初期設定

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

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

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の設定

設定例を参考にconf/silhouette.confに設定を書き換えます。

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ステータスだけ返すようにしています。

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でアクティベート処理をしています。

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/Useractivated: Booleanで判定しています。
アクティベートに成功した場合はOk, 失敗た場合はBadRequestを返すようにしています。
activateはHTMLをリクエスト元に返す必要がないので、HTTPステータスだけ返しています。

サインイン

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

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

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を継承したWithProviderisAuthorizedの実装を与えることで認可の判定をしています。

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.SecuredActionWithProviderを渡すことで、認可を入れています。

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

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

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

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

その他

CORS

デフォルトだとCORSの設定でブラウザからアクセスできるレスポンスヘッダーは何もないので、ドキュメントを参考に application.conf にplay.filters.cors.exposedHeadersの設定を加えます。

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

デバッグ

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

build.sbt
fork in run := false

JWTの有効性

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

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の認証/認可周りを簡単に実装できることがわかりました。
パスワード忘れた時のリセット周りだとか、自前で実装しなくていいので便利ですね!