これは Play2.x のアプリケーションに認証/認可の機能を手軽に組み込むためのモジュールです。
このモジュールは Play2.x の __Scala__版を対象としています。 Java版には Deadbolt 2 というモジュールがありますので こちらも参考にして下さい。
Play2.2.1 で動作確認をしています。
標準で提供されている Security
トレイトでは、ユーザを識別する識別子を規定していません。
サンプルアプリケーションのように、E-mailアドレスやユーザIDなどを識別子として利用した場合、 万が一Cookieが流出した場合に、即座にSessionを無効にすることができません。
このモジュールでは、暗号論的に安全な乱数生成器を使用してセッション毎にuniqueなSessionIDを生成します。 万が一Cookieが流失した場合でも、再ログインによるSessionIDの無効化やタイムアウト処理を行うことができます。
標準で提供されている Security
トレイトでは、認証後に Action
を返します。
これでは認証/認可以外にも様々なAction合成を行いたい場合にネストが深くなって非常に記述性が低くなります。
このモジュールでは Either[PlainResult, User]
を返すインターフェイスを用意することで、
柔軟に他の操作を組み合わせて使用することができます。
Play2.2 から Result
が非推奨になりました。その影響で play2.auth のインターフェイスも変更されています。
0.10.1以前からバージョンアップを行う方はご注意ください。
Build.scala
もしくは build.sbt
にライブラリ依存性定義を追加します。
"jp.t2v" %% "play2-auth" % "0.11.1",
"jp.t2v" %% "play2-auth-test" % "0.11.1" % "test"
For example: Build.scala
val appDependencies = Seq(
"jp.t2v" %% "play2-auth" % "0.11.1",
"jp.t2v" %% "play2-auth-test" % "0.11.1" % "test"
)
このモジュールはシンプルな Scala ライブラリとして作成されています。 play.plugins
ファイルは作成する必要ありません。
-
app/controllers
以下にjp.t2v.lab.play2.auth.AuthConfig
を実装したtrait
を作成します。// (例) trait AuthConfigImpl extends AuthConfig { /** * ユーザを識別するIDの型です。String や Int や Long などが使われるでしょう。 */ type Id = String /** * あなたのアプリケーションで認証するユーザを表す型です。 * User型やAccount型など、アプリケーションに応じて設定してください。 */ type User = Account /** * 認可(権限チェック)を行う際に、アクション毎に設定するオブジェクトの型です。 * このサンプルでは例として以下のような trait を使用しています。 * * sealed trait Permission * case object Administrator extends Permission * case object NormalUser extends Permission */ type Authority = Permission /** * CacheからユーザIDを取り出すための ClassManifest です。 * 基本的にはこの例と同じ記述をして下さい。 */ val idTag: ClassTag[Id] = classTag[Id] /** * セッションタイムアウトの時間(秒)です。 */ val sessionTimeoutInSeconds: Int = 3600 /** * ユーザIDからUserブジェクトを取得するアルゴリズムを指定します。 * 任意の処理を記述してください。 */ def resolveUser(id: Id)(implicit ctx: ExecutionContext): Future[Option[User]] = Account.findByIdAsync(id) /** * ログインが成功した際に遷移する先を指定します。 */ def loginSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[SimpleResult] = Future.successful(Redirect(routes.Message.main)) /** * ログアウトが成功した際に遷移する先を指定します。 */ def logoutSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[SimpleResult] = Future.successful(Redirect(routes.Application.login)) /** * 認証が失敗した場合に遷移する先を指定します。 */ def authenticationFailed(request: RequestHeader)(implicit ctx: ExecutionContext): Future[SimpleResult] = Future.successful(Redirect(routes.Application.login)) /** * 認可(権限チェック)が失敗した場合に遷移する先を指定します。 */ def authorizationFailed(request: RequestHeader)(implicit ctx: ExecutionContext): Future[SimpleResult] = Future.successful(Forbidden("no permission")) /** * 権限チェックのアルゴリズムを指定します。 * 任意の処理を記述してください。 */ def authorize(user: User, authority: Authority)(implicit ctx: ExecutionContext): Future[Boolean] = Future.successful { (user.permission, authority) match { case (Administrator, _) => true case (NormalUser, NormalUser) => true case _ => false } } /** * SessionID Cookieにsecureオプションを指定するか否かの設定です。 * デフォルトでは利便性のために false になっていますが、 * 実際のアプリケーションでは true にすることを強く推奨します。 */ override lazy val cookieSecureOption: Boolean = play.api.Play.current.configuration.getBoolean("auth.cookie.secure").getOrElse(true) }
-
次にログイン、ログアウトを行う
Controller
を作成します。 このController
に、先ほど作成したAuthConfigImpl
トレイトと、jp.t2v.lab.play2.auth.LoginLogout
トレイトを mixin します。object Application extends Controller with LoginLogout with AuthConfigImpl { /** ログインFormはアプリケーションに応じて自由に作成してください。 */ val loginForm = Form { mapping("email" -> email, "password" -> text)(Account.authenticate)(_.map(u => (u.email, ""))) .verifying("Invalid email or password", result => result.isDefined) } /** ログインページはアプリケーションに応じて自由に作成してください。 */ def login = Action { implicit request => Ok(html.login(loginForm)) } /** * ログアウト処理では任意の処理を行った後、 * gotoLogoutSucceeded メソッドを呼び出した結果を返して下さい。 * * gotoLogoutSucceeded メソッドは Future[SimpleResult] を返します。 * 以下のようにflashingなどを追加することもできます。 * * gotoLogoutSucceeded.map(_.flashing( * "success" -> "You've been logged out" * )) */ def logout = Action.async { implicit request => // do something... gotoLogoutSucceeded } /** * ログイン処理では認証が成功した場合、 * gotoLoginSucceeded メソッドを呼び出した結果を返して下さい。 * * gotoLoginSucceeded メソッドも gotoLogoutSucceeded と同じく Future[SimpleResult] を返します。 * 任意の処理を追加することも可能です。 */ def authenticate = Action.async { implicit request => loginForm.bindFromRequest.fold( formWithErrors => Future.successful(BadRequest(html.login(formWithErrors))), user => gotoLoginSucceeded(user.get.id) ) } }
-
最後は、好きな
Controller
に 先ほど作成したAuthConfigImpl
トレイトとjp.t2v.lab.play2.auth.AuthElement
トレイト を mixin すれば、認証/認可の仕組みを導入することができます。object Message extends Controller with AuthElement with AuthConfigImpl { // StackAction の 引数に権限チェック用の (AuthorityKey, Authority) 型のオブジェクトを指定します。 // 第二引数に RequestWithAttribute[AnyContent] => Result な関数を渡します。 // AuthElement は loggedIn[A](implicit RequestWithAttribute[A]): User というメソッドをもっています。 // このメソッドから認証/認可済みのユーザを取得することができます。 def main = StackAction(AuthorityKey -> NormalUser) { implicit request => val user = loggedIn val title = "message main" Ok(html.message.main(title)) } def list = StackAction(AuthorityKey -> NormalUser) { implicit request => val user = loggedIn val title = "all messages" Ok(html.message.list(title)) } def detail(id: Int) = StackAction(AuthorityKey -> NormalUser) { implicit request => val user = loggedIn val title = "messages detail " Ok(html.message.detail(title + id)) } // このActionだけ、Administrator でなければ実行できなくなります。 def write = aStackAction(AuthorityKey -> Administrator) { implicit request => val user = loggedIn val title = "write message" Ok(html.message.write(title)) } }
play2.auth では、version 0.8 からテスト用のサポートを提供しています。
FakeRequest
を使って Controller
のテストを行う際に、
ログイン状態のユーザを指定することができます。
package test
import org.specs2.mutable._
import play.api.test._
import play.api.test.Helpers._
import controllers.{AuthConfigImpl, Messages}
import jp.t2v.lab.play2.auth.test.Helpers._
class ApplicationSpec extends Specification {
object config extends AuthConfigImpl
"Messages" should {
"return list when user is authorized" in new WithApplication {
val res = Messages.list(FakeRequest().withLoggedIn(config)(1))
contentType(res) must equalTo("text/html")
}
}
}
-
まず
jp.t2v.lab.play2.auth.test.Helpers._
を import します。 -
次にテスト対象に mixin されているものと同じ
AuthConfigImpl
のインスタンスを生成します。object config extends AuthConfigImpl
-
FakeRequest
のwithLoggedIn
メソッドを呼び出します。- 第一引数には、先ほど定義した
AuthConfigImpl
インスタンス - 第二引数には、このリクエストがログインしている事にする、対象のユーザIDを指定します。
- 第一引数には、先ほど定義した
以上で play2.auth を使用したコントローラのテストを行うことができます。
例えば SNS のようなアプリケーションでは、メッセージの編集といった機能があります。
しかしこのメッセージ編集は、自分の書いたメッセージは編集可能だけども、 他のユーザが書いたメッセージは編集禁止にしなくてはいけません。
そういった場合にも以下のように Authority
を関数にすることで簡単に対応が可能です。
trait AuthConfigImpl extends AuthConfig {
// 他の設定省略
type Authority = User => Future[Boolean]
def authorize(user: User, authority: Authority)(implicit ctx: ExecutionContext): Future[Boolean] = authority(user)
}
object Application extends Controller with AuthElement with AuthConfigImpl {
private def sameAuthor(messageId: Int)(account: Account): Future[Boolean] =
Message.getAutherAsync(messageId).map(_ == account)
def edit(messageId: Int) = StackAction(AuthorityKey -> sameAuthor(messageId)) { request =>
val target = Message.findById(messageId)
Ok(html.message.edit(messageForm.fill(target)))
}
}
アプリケーションの任意のページにアクセスしてきた際に、 未ログイン状態であればログインページに遷移し、 ログインが成功した後に最初にアクセスしてきたページに戻したい、といった要求があります。
その場合も以下のようにするだけで簡単に実現できます。
trait AuthConfigImpl extends AuthConfig {
// 他の設定省略
def authenticationFailed(request: RequestHeader)(implicit ctx: ExecutionContext): Future[SimpleResult] =
Future.successful(Redirect(routes.Application.login).withSession("access_uri" -> request.uri))
def loginSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[SimpleResult] = {
val uri = request.session.get("access_uri").getOrElse(routes.Message.main.url)
Future.successful(Redirect(uri).withSession(request.session - "access_uri"))
}
}
トップページなどにおいて、未ログイン状態でも画面を正常に表示し、
ログイン状態であればユーザ名などを表示する、といったことがしたい場合、
以下のように AuthElement
の代わりに OptionalAuthElement
を使用することで実現することができます。
OptionalAuthElement
を使用する場合、Authority
は必要ありません。
object Application extends Controller with OptionalAuthElement with AuthConfigImpl {
// maybeUser is an Option[User] instance.
def index = StackAction { implicit request =>
val maybeUser: Option[User] = loggedIn
val user: User = maybeUser.getOrElse(GuestUser)
Ok(html.index(user))
}
}
認証だけ行うこともできます。
AuthElement
の代わりに AuthenticationElement
を使うだけです。
この場合、 AuthorityKey
の指定は必要ありません。
object Application extends Controller with AuthenticationElement with AuthConfigImpl {
def index = StackAction { implicit request =>
val user: User = loggedIn
Ok(html.index(user))
}
}
通常のアクセスで認証が失敗した場合にはログイン画面にリダイレクトさせたいけれども、 Ajaxリクエストの場合には単に401を返したい場合があります。
その場合でも以下の様に authenticationFailed
で分岐すれば実現することができます。
def authenticationFailed(request: RequestHeader)(implicit ctx: ExecutionContext) = Future.successful {
request.headers.get("X-Requested-With") match {
case Some("XMLHttpRequest") => Unauthorized("Authentication failed")
case _ => Redirect(routes.Application.login)
}
}
stackable-controller の仕組みを使用します。
例えば、CSRF対策で各Actionでトークンのチェックをしたい、としましょう。
全てのActionで毎回チェックロジックを書くのは大変なので、以下のようなトレイトを作成します。
trait TokenValidateElement extends StackableController {
self: Controller =>
// Token の発行処理は省略
private val tokenForm = Form("token" -> text)
private def validateToken(request: Request[AnyContent]): Boolean = (for {
tokenInForm <- tokenForm.bindFromRequest(request).value
tokenInSession <- request.session.get("token")
} yield tokenInForm == tokenInSession).getOrElse(false)
override proceed[A](reqest: RequestWithAttributes[A])(f: RequestWithAttributes[A] => Future[SimpleResult]): Future[SimpleResult] = {
if (validateToken(request)) super.proceed(request)(f)
else Future.successful(BadRequest)
}
}
この TokenValidateElement
トレイトと AuthElement
トレイトを両方mixinすることで、
CSRFトークンチェックと認証/認可を両方行うことができます。
object Application extends Controller with TokenValidateElement with AuthElement with AuthConfigImpl {
// Token の発行処理は省略
def page1 = StackAction(AuthorityKey -> NormalUser) { implicit request =>
// do something
Ok(html.page1("result"))
}
def page2 = StackAction(AuthorityKey -> NormalUser) { implicit request =>
// do something
Ok(html.page2("result"))
}
}
効率的なアプリケーションを作成するため、昨今ではReactiveなアプローチが人気を博しています。 Playはこういった非同期なアプローチが得意であり、ReactiveMongo や ScalikeJDBC-Async などといった非同期なライブラリを上手に使用する事ができます。
StackAction
の代わりに AsyncStack
を使用することで、 Future[SimpleResult] を返すアクションを簡単につくることができます。
trait HogeController extends AuthElement with AuthConfigImpl {
def hoge = AsyncStack { implicit req =>
val messages: Future[Seq[Message]] = AsyncDB.withPool { implicit s => Message.findAll }
messages.map(Ok(html.view.messages(_)))
}
}
このモジュールの標準実装はステートフルな実装になっています。 Play framefork が推奨するステートレスなポリシーを尊重したくはあるのですが、 ステートレスにすると次のようなセキュリティリスクが存在するため、標準では安全側に倒してあります。
例えば、インターネットカフェなどでサービスにログインし、 ログアウトするのを忘れて帰宅してしまった、といった場合。 ステートレスではその事実に気付いても即座にそのSessionを無効にすることができません。 標準実装ではログイン時に、それより以前のSessionを無効にしてます。 したがってこの様な事態に気付いた場合、即座に再ログインすることでSessionを無効化することができます。
このようなリスクを踏まえ、それでもステートレスにしたい場合、 以下のように設定することでステートレスにすることができます。
trait AuthConfigImpl extends AuthConfig {
// 他の設定省略
override lazy val idContainer: IdContainer[Id] = new CookieIdContainer[Id]
}
IdContainer
は SessionID および UserID を紐付ける責務を負っています。
この実装を切り替えることで、例えば RDBMS に認証情報を登録するといった事も可能です。
なお、CookieIdContainer
ではSessionタイムアウトは未サポートとなっています。
git clone https://github.com/t2v/play2-auth.git
cd play2-auth
play "project sample" play run
- ブラウザで
http://localhost:9000/
にアクセス-
「Database 'default' needs evolution!」と聞かれるので
Apply this script now!
を押して実行します -
適当にログインします
アカウントは以下の3アカウントが登録されています。
Email | Password | Permission [email protected] | secret | Administrator [email protected] | secret | NormalUser [email protected] | secret | NormalUser
-
このモジュールは Play2.x の Cache API を利用しています。
標準実装の Ehcache では、サーバを分散させた場合に正しく認証情報を扱えない場合があります。
サーバを分散させる場合には Memcached Plugin 等を利用してください。
このモジュールは Apache Software License, version 2 の元に公開します。
詳しくは LICENSE
ファイルを参照ください。