Edited at

Play Framework 向け 認証認可ライブラリ Silhouette 3.0 の中身と利用法をお勉強して実際に実装してみた話

More than 3 years have passed since last update.

Play framework の認証認可ライブラリといえば SecureSocial が有名なのですが、どうも Play 2.4 以降で使おうと思うといろいろ大変なようなので、SecureSocial の流れをくむ比較的新しい認証認可ライブラリ Silhouette について調べました。

Silhouette には Seed project が用意されているので、それを使えば簡単に利用することができるのですが、中身が不明なものをセキュリティで利用するのは気が引けたので、空の Play Application に Silhouette を取り込む形で実装してみて、Silhouette の使い方と、内部のメカニズムをざっくり追いました。

だいたいは Silhouette のチュートリアルを参考にまとめたものです。


Silhouette とは

認証認可ライブラリです。Play のエンドポイント(Controller)での、認証・認可によるアクセス制御を行ってくれます。


HogeController.scala

package controllers

import javax.inject._
import play.api._
import play.api.mvc._

@Singleton
class ViewController @Inject()(implicit webJarAssets: WebJarAssets,
val messagesApi: MessagesApi,
val env: Environment[User, CookieAuthenticator]) extends Silhouette[User, CookieAuthenticator] {

def index = SecuredAction { // 認証されていればアクセスできる
Ok(views.html.index("Your new application is ready."))
}

}



Silhouette の構成と設定

Silhouette は apiimpl パッケージで構成されています。名前の通り api は処理に必要なインターフェースを定義していて、impl はそのデフォルト実装を提供しており、この impl を独自実装して差し替えることで、独自の認証認可機構を必要な部分だけ実装することで実現することができます。ありがとう Silhouette。


Silhouette と Environment

Silhouette は Environment というモジュールに依存しており、Environment は IdentityService、AuthenticatorService、RequestProvider、EventBus の4つのサービスを保持していて、IdentityService を除く3つのサービスは impl でデフォルト実装が提供されているのでそのままデフォルトの実装を利用することもできる。


IdentityService

IdentityService については、必ず実装を提供する必要がある。ざっくり言うと、ユーザーモデルの永続化を提供すればいい感じです。


UserService.scala

package models.services

import java.util.UUID
import javax.inject.Inject

import com.mohiva.play.silhouette.api.LoginInfo
import com.mohiva.play.silhouette.api.services.IdentityService
import models.User
import models.daos.UserDao

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class UserService @Inject() (userDao: UserDao) extends IdentityService[User] {

override def retrieve(loginInfo: LoginInfo): Future[Option[User]] = userDao.read(loginInfo)

def retrieve(id: UUID): Future[Option[User]] = userDao.read(id)

def save(user: User): Future[User] = userDao.update(user).flatMap {
case Some(u) => Future.successful(u)
case None => userDao.create(user)
}

def remove(id: UUID): Future[Option[User]] = userDao.delete(id)

}


IdentityService を継承することで、実装を与えています。IdentityService で実装が必要とされているのは retrieve(loginInfo: LoginInfo): Future[Option[User]] だけです。DAO については適当に実装を与えてください。この設計によって、このサービスにおける User モデルは完全に Silhouette とは独立です。


HogeInfoDAO

似たような形で、利用する認証方式ごとに認証情報を永続化する実装を与える仕組みとして DelegableAuthInfoDAO があるので、それを継承して実装しましょう。例えば、ID とパスワードで認証する場合、DelegableAuthInfoDAO[PasswordInfo] を継承した PasswordInfoDAO を実装してあげます。


PasswordInfoDAO.scala

package models.daos

import javax.inject.Inject

import com.mohiva.play.silhouette.api.LoginInfo
import com.mohiva.play.silhouette.api.util.PasswordInfo
import com.mohiva.play.silhouette.impl.daos.DelegableAuthInfoDAO
import play.api.libs.json._
import play.modules.reactivemongo.json._
import reactivemongo.api.DB
import reactivemongo.play.json.collection._
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class PasswordInfoDAO @Inject() (db: DB) extends DelegableAuthInfoDAO[PasswordInfo] {

implicit val passwordInfoFormat = Json.format[PasswordInfo]
implicit val persistentPasswordInfoFormat = Json.format[PersistentPasswordInfo]

def collection = db.collection[JSONCollection]("password")

override def find(loginInfo: LoginInfo): Future[Option[PasswordInfo]] =
collection.find(Json.obj("loginInfo" -> loginInfo)).one[PersistentPasswordInfo].map {
case Some(pInfo) => Some(pInfo.passwordInfo)
case _ => None
}

override def update(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] =
collection.update(Json.obj("loginInfo" -> loginInfo), PersistentPasswordInfo(loginInfo, authInfo)).
map(_ => authInfo)

override def remove(loginInfo: LoginInfo): Future[Unit] =
collection.remove(Json.obj("loginInfo" -> loginInfo)).map(_ => ())

override def save(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] =
find(loginInfo).flatMap {
case Some(pInfo) => update(loginInfo, authInfo)
case None => add(loginInfo, authInfo)
}

override def add(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] =
collection.insert(PersistentPasswordInfo(loginInfo, authInfo)).map(_ => authInfo)

}

case class PersistentPasswordInfo(loginInfo: LoginInfo, passwordInfo: PasswordInfo)


DelegableAuthInfoDAO には必要な実装が5個あるので、利用している DB と相談していい感じに型合わせゲームをしましょう。ここでは MongoDB と Reactive Mongo でやってます。


Environment の構成

Environment を構成するのに必要なモジュールが揃ったところで、Environment を構成します。具体的には Guice のバインディングを利用して、実装を渡します。


Module.scala

import com.google.inject.{AbstractModule, Provides}

import com.mohiva.play.silhouette.api.repositories.AuthInfoRepository
import com.mohiva.play.silhouette.api.{Environment, EventBus}
import com.mohiva.play.silhouette.api.services.{AuthenticatorService, IdentityService}
import com.mohiva.play.silhouette.api.util._
import com.mohiva.play.silhouette.impl.authenticators.{CookieAuthenticator, CookieAuthenticatorService, CookieAuthenticatorSettings}
import com.mohiva.play.silhouette.impl.daos.DelegableAuthInfoDAO
import com.mohiva.play.silhouette.impl.providers.CredentialsProvider
import com.mohiva.play.silhouette.impl.repositories.DelegableAuthInfoRepository
import com.mohiva.play.silhouette.impl.util.{BCryptPasswordHasher, DefaultFingerprintGenerator, SecureRandomIDGenerator}
import models.User
import models.daos._
import models.services.UserService
import net.ceedubs.ficus.Ficus._
import net.ceedubs.ficus.readers.ArbitraryTypeReader._
import net.codingwell.scalaguice.ScalaModule
import play.api.Configuration
import play.api.libs.concurrent.Execution.Implicits._
import reactivemongo.api._

/**
* This class is a Guice module that tells Guice how to bind several
* different types. This Guice module is created when the Play
* application starts.
*
* Play will automatically use any class called `Module` that is in
* the root package. You can create modules in other locations by
* adding `play.modules.enabled` settings to the `application.conf`
* configuration file.
*/

class Module extends AbstractModule with ScalaModule {

override def configure() = {
bind[DB].toInstance {
import scala.concurrent.ExecutionContext.Implicits.global

val driver = new MongoDriver
val connection = driver.connection(List("localhost:27017"))
connection.apply("silhouette")
}
bind[UserDAO].to[UserDAOImpl]
bind[MailTokenDAO].to[MailTokenDAOImpl]
bind[IdentityService[User]].to[UserService]
bind[MailTokenDAO].to[MailTokenDAOImpl]
bind[DelegableAuthInfoDAO[PasswordInfo]].to[PasswordInfoDAO]
bind[IDGenerator].toInstance(new SecureRandomIDGenerator())
bind[PasswordHasher].toInstance(new BCryptPasswordHasher)
bind[FingerprintGenerator].toInstance(new DefaultFingerprintGenerator(false))
bind[EventBus].toInstance(EventBus())
bind[Clock].toInstance(Clock())
}

@Provides def provideEnvironment(identityService: IdentityService[User],
authenticatorService: AuthenticatorService[CookieAuthenticator],
eventBus: EventBus): Environment[User, CookieAuthenticator] = {
Environment[User, CookieAuthenticator](identityService, authenticatorService, Seq(), eventBus)
}

@Provides def provideAuthenticatorService(fingerprintGenerator: FingerprintGenerator,
idGenerator: IDGenerator,
configuration: Configuration,
clock: Clock): AuthenticatorService[CookieAuthenticator] = {
val config = configuration.underlying.as[CookieAuthenticatorSettings]("silhouette.authenticator")
new CookieAuthenticatorService(config, None, fingerprintGenerator, idGenerator, clock)
}

@Provides def provideCredentialsProvider(authInfoRepository: AuthInfoRepository,
passwordHasher: PasswordHasher): CredentialsProvider =
new CredentialsProvider(authInfoRepository, passwordHasher, Seq(passwordHasher))

@Provides def provideAuthInfoRepository(passwordInfoDAO: DelegableAuthInfoDAO[PasswordInfo]): AuthInfoRepository =
new DelegableAuthInfoRepository(passwordInfoDAO)

}


前半の def configure() では単純な抽象と実装のバインディングを設定しています。先ほど実装した UserService や PasswordInfoDAO を抽象である IdentityService[User] や DelegableAuthInfoDAO[PasswordInfo] に対してバインディングして、実装を提供しています。

後半の @Provides アノテーションは Guice のアノテーションで、@Provides 以下のメソッドの返り値の型に対して、そのメソッドの実際の返り値を提供します。ここで、

@Provides def provideEnvironment(identityService: IdentityService[User],

authenticatorService: AuthenticatorService[CookieAuthenticator],
eventBus: EventBus): Environment[User, CookieAuthenticator] = {
Environment[User, CookieAuthenticator](identityService, authenticatorService, Seq(), eventBus)
}

によって、Environment を構成しています。ここで identityService: IdentityService[User] については、Guice によって先ほどバインディングした UserService が渡されることになります。これで自前実装を取り込んだ Environment を構成することができました。

また、認証に必要な AuthenticatorService の設定値については、application.conf に以下の設定値を書き込んでおきます。Authenticator については次章にて。

authenticator.cookieName="authenticator"

authenticator.cookiePath="/"
authenticator.secureCookie=false
authenticator.httpOnlyCookie=true
authenticator.useFingerprinting=true
authenticator.authenticatorIdleTimeout=5 days
authenticator.authenticatorExpiry=365 days

設定値は Authenticator の種類によって違うので割愛。


Silhouette の認可の仕組み - Authenticator

Silhouette では認証認可の状態を追跡するために AuthenticatorService というサービスがあります。これには実装が複数あり、状態をどのように保持するか・通知するかという違いで、本質的には認証できたらその情報を元に Authenticator を作成して、クライアントに送付します。

次回以降のアクセス時に、クライアントはこの Authenticator をサーバーに Request に載せて送付することで、自動的に認可を行います。

ということで、Sign Up するときに Authenticator を作成して、クライアントに渡してみます。


SignUpController.scala

package controllers

import java.util.UUID
import javax.inject.Inject

import com.mohiva.play.silhouette.api._
import com.mohiva.play.silhouette.api.repositories.AuthInfoRepository
import com.mohiva.play.silhouette.api.services.AvatarService
import com.mohiva.play.silhouette.api.util.PasswordHasher
import com.mohiva.play.silhouette.impl.authenticators.CookieAuthenticator
import com.mohiva.play.silhouette.impl.providers._
import forms.SignUpForm
import models.User
import models.services.{MailService, UserService}
import play.api.i18n.{Messages, MessagesApi}
import play.api.libs.concurrent.Execution.Implicits._
import play.api.mvc.Action

import scala.concurrent.Future

class SignUpController @Inject() (
val messagesApi: MessagesApi,
val env: Environment[User, CookieAuthenticator],
userService: UserService,
authInfoRepository: AuthInfoRepository,
passwordHasher: PasswordHasher,
mailService: MailService)
extends Silhouette[User, CookieAuthenticator] {

def signUp = Action.async { implicit request =>
SignUpForm.form.bindFromRequest.fold(
form => Future.successful(BadRequest(views.html.signUp(form))),
data => {
val loginInfo = LoginInfo(CredentialsProvider.ID, data.email)
userService.retrieve(loginInfo).flatMap {
case Some(user) =>
Future.successful(Redirect(routes.ApplicationController.signUp()).flashing("error" -> Messages("user.exists")))
case None =>
val authInfo = passwordHasher.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,
mailConfirmed = None
)
for {
user <- userService.save(user)
authInfo <- authInfoRepository.add(loginInfo, authInfo)
authenticator <- env.authenticatorService.create(loginInfo)
value <- env.authenticatorService.init(authenticator)
result <- env.authenticatorService.embed(value, Redirect(routes.ApplicationController.index()))
} yield {
mailService.sendConfirm(user)
env.eventBus.publish(SignUpEvent(user, request, request2Messages))
env.eventBus.publish(LoginEvent(user, request, request2Messages))
result
}
}
}
)
}
}


SignUpForm にバインディングされた POST リクエストを元に、新規に User を作成して、UserService に保存し、authInfo(Password) を authInfoRepository(= PasswordInfoDAO) を通して保存しています。最後に authenticatorService を通して Authenticator を Response に乗せて返しています。今回は CookieAuthenticator を利用してるので、実際には Cookie に Authenticator が保存されるようになります。

次回以降は、クライアントは Cookie に乗っている Authenticator を毎回アクセス時に送信し、Silhouette はその正真性を確認して認可を行います。


エンドポイントプロテクション

ここまで設定・実装を行うと、実際にリソースに対するアクセスに対して認可処理をさせることができるようになります。

class ApplicationController @Inject() (

val messagesApi: MessagesApi,
val env: Environment[User, CookieAuthenticator])
extends Silhouette[User, CookieAuthenticator] {

def index = SecuredAction.async { implicit request =>
Future.successful(Ok(views.html.home(request.identity)))
}
}

認証がされていれば、Authenticator の情報を元に SecuredAction にアクセスできます。しかも request は Silhouette によって加工済みで、Authenticator => LoginInfo => IdentityService[User].retlieve(loginInfo: LoginInfo) から取り出される Identity = User が request.identity にセットされています。

もし、認証がされていない = Authenticator を持っていない状態で、この index にアクセスした場合、util.ErrorHandler で定義されているエラー画面にリダイレクトされます。

ということで、認証認可の仕組みを Silhouette で実装することができました。


まとめ

本当にざっくりとですが Silhouette の概要と、実装手法を学びました。Silhouette のドキュメントはかなり充実しているのですが、すべての要素が並列的に説明されているので、全体の構造を把握するのは少し難しかったです。

おしまい。


参考

Play framework security with Silhouette

Playful web development, Part 1: Manage user authentication with the Play Framework and Scala

play-silhouette-mail-confirm-seed