これは Play2.x のアプリケーションに認証/認可の機能を手軽に組み込むためのモジュールです。
このモジュールは Play2.x の __Scala__版を対象としています。
Play2.4.2 で動作確認をしています。
標準で提供されている Security
トレイトでは、ユーザを識別する識別子を規定していません。
サンプルアプリケーションのように、E-mailアドレスやユーザIDなどを識別子として利用した場合、 万が一Cookieが流出した場合に、即座にSessionを無効にすることができません。
このモジュールでは、暗号論的に安全な乱数生成器を使用してセッション毎にuniqueなSessionIDを生成します。 万が一Cookieが流失した場合でも、再ログインによるSessionIDの無効化やタイムアウト処理を行うことができます。
標準で提供されている Security
トレイトでは、認証後に Action
を返します。
これでは認証/認可以外にも様々なAction合成を行いたい場合にネストが深くなって非常に記述性が低くなります。
このモジュールでは Stackable-Controllerの実装や、
ActionRefiner
による実装など、柔軟に他の操作を組み合わせて使用する方法を提供しています。
Play2.3.x 向けの使用方法は 0.13.5 READMEをご参照ください。 Play2.2.x 向けの使用方法は 0.11.1 READMEをご参照ください。
Play2.2 から Result
が非推奨になりました。その影響で play2.auth のインターフェイスも変更されています。
0.10.1以前からバージョンアップを行う方はご注意ください。
Build.scala
もしくは build.sbt
にライブラリ依存性定義を追加します。
"jp.t2v" %% "play2-auth" % "0.14.2",
"jp.t2v" %% "play2-auth-social" % "0.14.2", // ソーシャルログイン
"jp.t2v" %% "play2-auth-test" % "0.14.2" % "test",
play.sbt.Play.autoImport.cache // デフォルトのIdContainerを使う場合のみ必要です
For example: Build.scala
val appDependencies = Seq(
"jp.t2v" %% "play2-auth" % "0.14.2",
"jp.t2v" %% "play2-auth-social" % "0.14.2",
"jp.t2v" %% "play2-auth-test" % "0.14.2" % "test",
play.sbt.Play.autoImport.cache // デフォルトのIdContainerを使う場合のみ必要です
)
このモジュールはシンプルな 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 Role * case object Administrator extends Role * case object NormalUser extends Role */ type Authority = Role /** * CacheからユーザIDを取り出すための ClassTag です。 * 基本的にはこの例と同じ記述をして下さい。 */ 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[Result] = Future.successful(Redirect(routes.Message.main)) /** * ログアウトが成功した際に遷移する先を指定します。 */ def logoutSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[Result] = Future.successful(Redirect(routes.Application.login)) /** * 認証が失敗した場合に遷移する先を指定します。 */ def authenticationFailed(request: RequestHeader)(implicit ctx: ExecutionContext): Future[Result] = Future.successful(Redirect(routes.Application.login)) /** * 認可(権限チェック)が失敗した場合に遷移する先を指定します。 */ override def authorizationFailed(request: RequestHeader, user: User, authority: Option[Authority])(implicit context: ExecutionContext): Future[Result] = { Future.successful(Forbidden("no permission")) } /** * 権限チェックのアルゴリズムを指定します。 * 任意の処理を記述してください。 */ def authorize(user: User, authority: Authority)(implicit ctx: ExecutionContext): Future[Boolean] = Future.successful { (user.role, authority) match { case (Administrator, _) => true case (NormalUser, NormalUser) => true case _ => false } } /** * (Optional) * SessionID Tokenの保存場所の設定です。 * デフォルトでは Cookie を使用します。 */ override lazy val tokenAccessor = new CookieTokenAccessor( /* * cookie の secureオプションを使うかどうかの設定です。 * デフォルトでは利便性のために false になっていますが、 * 実際のアプリケーションでは true にすることを強く推奨します。 */ cookieSecureOption = play.api.Play.isProd(play.api.Play.current), cookieMaxAge = Some(sessionTimeoutInSeconds) ) }
-
次にログイン、ログアウトを行う
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[Result] を返します。 * 以下のようにflashingなどを追加することもできます。 * * gotoLogoutSucceeded.map(_.flashing( * "success" -> "You've been logged out" * )) */ def logout = Action.async { implicit request => // do something... gotoLogoutSucceeded } /** * ログイン処理では認証が成功した場合、 * gotoLoginSucceeded メソッドを呼び出した結果を返して下さい。 * * gotoLoginSucceeded メソッドも gotoLogoutSucceeded と同じく Future[Result] を返します。 * 任意の処理を追加することも可能です。 */ 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 = StackAction(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 result = Messages.list(FakeRequest().withLoggedIn(config)(1))
contentType(result) 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[Result] =
Future.successful(Redirect(routes.Application.login).withSession("access_uri" -> request.uri))
def loginSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[Result] = {
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で毎回チェックロジックを書くのは大変なので、以下のようなトレイトを作成します。
import jp.t2v.lab.play2.stackc.{RequestWithAttributes, StackableController}
import scala.concurrent.Future
import play.api.mvc.{Result, Request, Controller}
import play.api.data._
import play.api.data.Forms._
trait TokenValidateElement extends StackableController {
self: Controller =>
// Token の発行処理は省略
private val tokenForm = Form("token" -> text)
private def validateToken(request: Request[_]): Boolean = (for {
tokenInForm <- tokenForm.bindFromRequest()(request).value
tokenInSession <- request.session.get("token")
} yield tokenInForm == tokenInSession).getOrElse(false)
override def proceed[A](request: RequestWithAttributes[A])(f: RequestWithAttributes[A] => Future[Result]): Future[Result] = {
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[Result] を返すアクションを簡単につくることができます。
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: AsyncIdContainer[Id] = AsyncIdContainer(new CookieIdContainer[Id])
}
IdContainer
は SessionID および UserID を紐付ける責務を負っています。
この実装を切り替えることで、例えば RDBMS に認証情報を登録するといった事も可能です。
なお、CookieIdContainer
ではSessionタイムアウトは未サポートとなっています。
Play2.2 から ActionBuilder
が導入され、
Play2.3 から ActionBuilder
をさらに抽象化した ActionFunction
が導入されました。
ActionFunction
の具象インターフェイスとして ActionBuilder
と ActionRefiner
があり、
更に ActionRefiner
の具象インターフェイスとして ActionTransformer
と ActionFilter
が存在しています。
これらを組み合わせて様々な処理を合成した ActionBuilder
を作成できるようになっています。
そのため、play2-auth でも様々な ActionFunction
の実装を提供しています。
もし、他のライブラリや既存コードが ActionFunction
を利用しているのであれば、
これらの使用も検討できます。
play2-auth が提供する ActionFunction
群を利用したい場合は、
AuthElement
の代わりに AuthActionBuilders
を Controller に mixin します。
StackAction
や AsyncStack
の代わりに、OptionalAuthAction
, AuthenticationAction
および AuthorizationAction
を利用することができます。
object Message extends Controller with AuthActionBuilders with AuthConfigImpl {
import scala.concurrent.Future.{successful => future}
/**
* `OptionalAuthAction` の型は `ActionBuilder[OptionalAuthRequest]` です。
* つまり、`OptionalAuthRequest => Result` という関数を受け取り `Action` を作成します。
*
* `OptionalAuthRequest` は `user: Option[User]` というフィールドを持っています。
* 認証が成功すれば `Some` を、失敗すれば `None` を保持しています。
* 認可は行いません。
*/
def index = OptionalAuthAction.async { request =>
val maybeUser: Option[User] = request.user
future(Ok(view.html.index(maybeUser.getOrElse(GuestUser))))
}
/**
* `AuthenticationAction` の型は `ActionBuilder[AuthRequest]` です。
* つまり、`AuthRequest => Result` という関数を受け取り `Action` を作成します。
*
* `AuthRequest` は `user: User` というフィールドを持っています。
* 認証が成功していれば、受け取った `AuthRequest => Result` を実行し、
* 失敗していれば、`AuthConfig` で定義された `authenticationFailed` を返す
* `Action` を生成します。
* 認可は行いません。
*/
def notNeedAuthorization = AuthenticationAction.async { request =>
val user: User = request.user
future(Ok(view.html.messages(user)))
}
/**
* `AuthorizationAction` は `Authority` を受け取って `ActionBuilder[AuthRequest]` を返す関数です。
*
* 認証が成功していれば、認可を行い、
* 失敗していれば、`AuthConfig` で定義された `authenticationFailed` を返します。
* 認可が成功していれば `AuthRequest => Result` を実行し、
* 失敗していれば、`AuthConfig` で定義された `authorizationFailed` を返す `Action` を生成します。
*/
def needAuthorization = AuthorizationAction(Admin).async { request =>
val user: User = request.user
future(Ok(view.html.messages(user)))
}
}
上記の OptionalAuthAction
, AuthenticationAction
および AuthorizationAction
は ActionBuilder
なので、
このままでは他の ActionBuilder
と合成することはできません。
他の ActionBuilder
と合成が可能なように、 OptionalAuthFunction
, AuthenticationRefiner
および AuthorizationFilter
が定義されています。
それぞれの型は以下のようになっています。
val OptionalAuthFunction: ActionFunction[Request, OptionalAuthRequest]
val AuthenticationRefiner: ActionRefiner[OptionalAuthRequest, AuthRequest]
def AuthorizationFilter(authority: Authority): ActionFilter[AuthRequest]
したがって、他のライブラリで提供された、もしくは自分で定義した
ActionBuilder[Request]
が存在していれば、下記のように合成することが可能です。
object MyCoolAction extends ActionBuilder[Request] {
...
}
object MyController extends Controller with AuthActionBuilders with AuthConfigImpl {
val MyCoolOptionalAuthAction: ActionBuilder[OptionalAuthRequest] =
MyCoolAction andThen OptionalAuthFunction
val MyCoolAuthenticationAction: ActionBuilder[AuthRequest] =
MyCoolOptionalAuthAction andThen AuthenticationRefiner
def MyCoolAuthorizationAction(authority: Authority): ActionBuilder[AuthRequest] =
MyCoolAuthenticationAction andThen AuthorizationFilter(authority)
def index = MyCoolAuthorizationAction(Admin).async {
...
}
}
上記では ActionBuilder[Request]
と合成する例を示しました。
しかし、実際には ActionBuilder
が、独自のリクエスト型を扱っている場合があります。
例えばAction単位でトランザクションを表すようなものを考えた場合、
以下のような ActionBuilder
を定義するかもしれません。
case class TxRequest[A](session: DBSession, underlying: Request[A]) extends WrappedRequest[A](underlying)
object TxAction extends ActionBuilder[TxRequest] {
override def invokeBlock[A](request: Request[A], block: (TxRequest[A]) => Future[Result]): Future[Result] = {
import scalikejdbc.TxBoundary.Future._
implicit val ctx = executionContext
DB.localTx { session =>
block(new TxRequest(session, request))
}
}
}
こうした場合、OptionalAuthFunction
はあくまで ActionFunction[Request, OptionalAuthRequest]
のため
TxAction
と合成することができません。
また、仮に合成ができたとしても OptionalAuthRequest
は TxRequest
の持つ session
の事を知りようが無いので、
実際のAction処理中で DBSession
を扱うことができません。
そこで play2-auth ではこれらの仕組みを更に抽象化した仕組みを提供しています。
下記のように GenericOptionalAuthRequest
や GenericAuthRequest
また、
GenericOptionalAuthFunction
, GenericAuthenticationRefiner
および GenericAuthorizationFilter
を使用すれば、
TxAction
のような ActionBuilder
とも合成が可能になります。
object MyController extends Controller with AuthActionBuilders with AuthConfigImpl {
type OptionalAuthTxRequest[A] = GenericOptionalAuthRequest[A, TxRequest]
type AuthTxRequest[A] = GenericAuthRequest[A, TxRequest]
val OptionalAuthTxAction: ActionBuilder[OptionalAuthTxRequest] =
composeOptionalAuthAction(TxAction)
val AuthenticationTxAction: ActionBuilder[AuthTxRequest] =
composeOptionalAuthAction(TxAction)
def AuthorizationTxAction(authority: Authority): ActionBuilder[AuthTxRequest] =
composeAuthorizationAction(TxAction)(authority)
/**
* GenericOptionalAuthRequest および GenericAuthRequest は、
* 第2型引数で指定されたリクエスト型を underlying というフィールドで提供します。
* したがって、AuthTxRequest では、 TxRequest から DBSession を取得することが可能です。
*/
def index = AuthorizationTxAction(Admin).async { request =>
val user: User = request.user
val session: DBSession = request.underlying.session
...
}
}
この様にして、play2-auth では、任意の ActionBuilder
と合成できる仕組みを提供しています。
しかし、独自リクエスト型を持つ ActionBuilder
が複数存在し、その全てを合成しようとすると、
Play2 の現在の仕組みではできません。
したがって、基本的には [Stackable-Controller] の利用を推奨いたします。
git clone https://github.com/t2v/play2-auth.git
cd play2-auth
sbt "project sample" run
- ブラウザで
http://localhost:9000/
にアクセス-
「Database 'default' needs evolution!」と聞かれるので
Apply this script now!
を押して実行します -
適当にログインします
アカウントは以下の3アカウントが登録されています。
Email | Password | Role [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
ファイルを参照ください。