はじめに
ReactとPlayで認証はどのようにやればいいかと思い調べてみました。
認証の基本的な流れについてはこちらを参考にしました。認証サービスとしてAPIのエンドポイントを用意して、Reactアプリケーションから認証する方法です。
認証にはJWTを使用します。またJWT内にロールを入れることで認可機能を追加します。
WebアプリケーションフレームワークにはPlayを使用しています。
認証と認可のライブラリにはSilhouetteを使用します。一からだと分かりづらいのでplay-silhouette-seedのテンプレートを使用しています。
これを少し変えてJWT認証をしてみたいと思います。
Silhouetteの概要についてはこちらを参照しました。
使用したバージョンです。
- 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
を使用します。
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 {
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にアクセスできるようにします。
- サインアップ (認証に必要なメールアドレスやユーザー名を登録します)
- アクティベート (メールアドレスを有効化します)
- サインイン (JWTを取得します)
- JWTを使った認証
サインアップ
サインアップに以下の4つの情報をJSONとしてPOST bodyに入れて送ります
firstName, lastName, fullName, email
ほとんど変えていないのですが、レスポンスにHTMLは必要ないのでHTTPステータスだけ返すようにしています。
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のアクティベート用のリンクを送らなければならず動作を確認しづらいので、標準出力にメールの内容が表示されるようにします。
play.mailer {
mock = true
host = localhost
}
POSTすると標準出力にメール内容が出力されると思います。
リンクをGETするとアクティベートします。
ActivateAccountController
でアクティベート処理をしています。
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ステータスだけ返しています。
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
の実装を与えることで認可の判定をしています。
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
を渡すことで、認可を入れています。
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
などのエラー関数をオーバーライドします。
override def onNotAuthorized(implicit request: RequestHeader) = {
Future.successful(Forbidden)
}
上記のように認証されていなければ Forbidden を返すようにしています。
その他
CORS
デフォルトだとCORSの設定でブラウザからアクセスできるレスポンスヘッダーは何もないので、ドキュメントを参考に application.conf にplay.filters.cors.exposedHeadersの設定を加えます。
play.filters.cors.exposedHeaders = ["x-auth-token"]
デバッグ
IntelliJ IDEAでデバッグする時JVMをforkしてしまうとデバッグできないので、forkしないようにしておきます。
fork in run := false
JWTの有効性
SecuredAciton
からRequestHandler
が呼ばれますが、そこで認証のチェックをしています。
case Some(a) if a.isValid => environment.identityService.retrieve(a.loginInfo).map(i => Some(Left(a)) -> i)
この部分でLoginInfoを使って Identity を探しています、もしあれば認証が通ります。
JWTが正しいだけではなく、 Identity が存在するかも見ています。なので Identity を永続化しておく必要があります。そうしないとアプリケーションを再起動した時に有効なJWTでも認証が通らなくなってしまいます。
永続化するエンティティ
外部(DBなど)に保存できるのは
-
Identity
を継承したUser
クラス PasswordInfo
-
JWTAuthenticator
の3つがあります。
play-silhouette-seedでは全てメモリ何保存するようにDAOが実装されています。
なので、実際にはDBなどに永続化する必要があります。
おわりに
Silhouetteを使ってReactの認証/認可周りを簡単に実装できることがわかりました。
パスワード忘れた時のリセット周りだとか、自前で実装しなくていいので便利ですね!