これは Play2.x のアプリケーションに認証/認可の機能を手軽に組み込むためのモジュールです。
このモジュールは Play2.x の __Scala__版を対象としています。 Java版には Deadbolt 2 というモジュールがありますので こちらも参考にして下さい。
Play2.0.4 及び Play2.1.0 で動作確認をしています。
標準で提供されている Security
トレイトでは、ユーザを識別する識別子を規定していません。
サンプルアプリケーションのように、E-mailアドレスやユーザIDなどを識別子として利用した場合、 万が一Cookieが流出した場合に、即座にSessionを無効にすることができません。
このモジュールでは、暗号論的に安全な乱数生成器を使用してセッション毎にuniqueなSessionIDを生成します。 万が一Cookieが流失した場合でも、再ログインによるSessionIDの無効化やタイムアウト処理を行うことができます。
標準で提供されている Security
トレイトでは、認証後に Action
を返します。
これでは認証/認可以外にも様々なAction合成を行いたい場合にネストが深くなって非常に記述性が低くなります。
このモジュールでは Either[PlainResult, User]
を返すインターフェイスを用意することで、
柔軟に他の操作を組み合わせて使用することができます。
Build.scala
もしくは build.sbt
にライブラリ依存性定義を追加します。
-
Play2.0.4版
"jp.t2v" %% "play20.auth" % "0.5"
-
Play2.1-RC1版
"jp.t2v" %% "play21.auth" % "0.7"
For example: Build.scala
val appDependencies = Seq(
"jp.t2v" %% "play21.auth" % "0.7"
)
val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA)
このモジュールはシンプルな Scala ライブラリとして作成されています。 play.plugins
ファイルは作成する必要ありません。
-
app/controllers
以下にjp.t2v.lab.play20.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] // for version 0.5 as follows // val idManifest: ClassManifest[Id] = classManifest[Id] /** * セッションタイムアウトの時間(秒)です。 */ val sessionTimeoutInSeconds: Int = 3600 /** * ユーザIDからUserブジェクトを取得するアルゴリズムを指定します。 * 任意の処理を記述してください。 */ def resolveUser(id: Id): Option[User] = Account.findById(id) /** * ログインが成功した際に遷移する先を指定します。 */ def loginSucceeded(request: RequestHeader): Result = Redirect(routes.Message.main) /** * ログアウトが成功した際に遷移する先を指定します。 */ def logoutSucceeded(request: RequestHeader): Result = Redirect(routes.Application.login) /** * 認証が失敗した場合に遷移する先を指定します。 */ def authenticationFailed(request: RequestHeader): Result = Redirect(routes.Application.login) /** * 認可(権限チェック)が失敗した場合に遷移する先を指定します。 */ def authorizationFailed(request: RequestHeader): Result = Forbidden("no permission") /** * 権限チェックのアルゴリズムを指定します。 * 任意の処理を記述してください。 */ def authorize(user: User, authority: Authority): Boolean = (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.play20.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 メソッドは Result を返します。 * jp.t2v.lab.play20.auth._ を import していた場合、 * 以下のようにflashingなどを追加することもできます。 * * gotoLogoutSucceeded.flashing( * "success" -> "You've been logged out" * ) */ def logout = Action { implicit request => // do something... gotoLogoutSucceeded } /** * ログイン処理では認証が成功した場合、 * gotoLoginSucceeded メソッドを呼び出した結果を返して下さい。 * * gotoLoginSucceeded メソッドも gotoLogoutSucceeded と同じく Result を返します。 * jp.t2v.lab.play20.auth._ を import して、任意の処理を追加することも可能です。 */ def authenticate = Action { implicit request => loginForm.bindFromRequest.fold( formWithErrors => BadRequest(html.login(formWithErrors)), user => gotoLoginSucceeded(user.get.id) ) } }
-
最後は、好きな
Controller
に 先ほど作成したAuthConfigImpl
トレイトとjp.t2v.lab.play20.auth.Auth
トレイト を mixin すれば、認証/認可の仕組みを導入することができます。object Message extends Controller with Auth with AuthConfigImpl { // authorizedAction は 第一引数に権限チェック用の Authority を取り、 // 第二引数に User => Request[AnyContent] => Result な関数を取り、 // Action を返します。 def main = authorizedAction(NormalUser) { user => implicit request => val title = "message main" Ok(html.message.main(title)) } def list = authorizedAction(NormalUser) { user => implicit request => val title = "all messages" Ok(html.message.list(title)) } def detail(id: Int) = authorizedAction(NormalUser) { user => implicit request => val title = "messages detail " Ok(html.message.detail(title + id)) } // このActionだけ、Administrator でなければ実行できなくなります。 def write = authorizedAction(Administrator) { user => implicit request => val title = "write message" Ok(html.message.write(title)) } }
例えば SNS のようなアプリケーションでは、メッセージの編集といった機能があります。
しかしこのメッセージ編集は、自分の書いたメッセージは編集可能だけども、 他のユーザが書いたメッセージは編集禁止にしなくてはいけません。
そういった場合にも以下のように Authority
を関数にすることで簡単に対応が可能です。
trait AuthConfigImpl extends AuthConfig {
// 他の設定省略
type Authority = User => Boolean
def authorize(user: User, authority: Authority): Boolean = authority(user)
}
object Application extends Controller with Auth with AuthConfigImpl {
private def sameAuthor(messageId: Int)(account: Account): Boolean =
Message.getAuther(messageId) == account
def edit(messageId: Int) = authorizedAction(sameAuthor(messageId)) { user => request =>
val target = Message.findById(messageId)
Ok(html.message.edit(messageForm.fill(target)))
}
}
アプリケーションの任意のページにアクセスしてきた際に、 未ログイン状態であればログインページに遷移し、 ログインが成功した後に最初にアクセスしてきたページに戻したい、といった要求があります。
その場合も以下のようにするだけで簡単に実現できます。
trait AuthConfigImpl extends AuthConfig {
// 他の設定省略
def authenticationFailed(request: RequestHeader): Result =
Redirect(routes.Application.login).withSession("access_uri" -> request.uri)
def loginSucceeded(request: RequestHeader): Result = {
val uri = request.session.get("access_uri").getOrElse(routes.Message.main.url)
Redirect(uri).withSession(request.session - "access_uri")
}
}
トップページなどにおいて、未ログイン状態でも画面を正常に表示し、
ログイン状態であればユーザ名などを表示する、といったことがしたい場合、
以下のように optionalUserAction
を使用することで実現することができます。
object Application extends Controller with Auth with AuthConfigImpl {
// maybeUser is an Option[User] instance.
def index = optionalUserAction { maybeUser => request =>
val user: User = maybeUser.getOrElse(GuestUser)
Ok(html.index(user))
}
}
通常のアクセスで認証が失敗した場合にはログイン画面にリダイレクトさせたいけれども、 Ajaxリクエストの場合には単に401を返したい場合があります。
その場合でも以下の様に authenticationFailed
で分岐すれば実現することができます。
def authenticationFailed(request: RequestHeader) = {
request.headers.get("X-Requested-With") match {
case Some("XMLHttpRequest") => Unauthorized("Authentication failed")
case _ => Redirect(routes.Application.login)
}
}
例えば、CSRF対策で各Actionでトークンのチェックをしたい、としましょう。
全てのActionで毎回チェックロジックを書くのは大変なので、普通はこんなActionの拡張をすると思います。
object Application extends Controller {
// Token の発行処理は省略
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)
private def validAction(f: Request[AnyContent] => Result) = Action { request =>
if (validateToken(request)) f(request)
else BadRequest
}
def page1 = validAction { request =>
// do something
Ok(html.page1("result"))
}
def page2 = validAction { request =>
// do something
Ok(html.page2("result"))
}
}
この validateToken に認証/認可の仕組みを組み込むにはどうすればいいでしょうか?
authorizedAction
メソッドの代わりに authorized
メソッドを使うことで簡単に実現ができます。
object Application extends Controller with Auth with AuthConfigImpl {
// Token の発行処理は省略
val tokenForm = Form("token" -> text)
private def validateToken(implicit request: Request[AnyContent]): Boolean = (for {
tokenInForm <- tokenForm.bindFromRequest(request).value
tokenInSession <- request.session.get("token")
} yield tokenInForm == tokenInSession).getOrElse(false)
private authAndValidAction(authority: Authority)(f: User => Request[AnyContent] => Result) =
Action { implicit request =>
(for {
user <- authorized(authority).right
_ <- Either.cond(validateToken, (), BadRequest).right
} yield f(user)(request)).merge
}
def page1 = authAndValidAction { user => request =>
// do something
Ok(html.page1("result"))
}
def page2 = authAndValidAction { user => request =>
// do something
Ok(html.page2("result"))
}
}
この例だけでは簡単さが実感できないかもしれません。 ではこれに更に pjax によって動的に Template を切り替えたいといったらどうでしょう?
その場合でも柔軟に取込むことができます。
private type Template = String => Html
private def pjax(implicit request: Request[AnyContent]): Template = {
if (request.headers.keys("X-PJAX")) {
html.pjaxTemplate.apply
} else {
val displayValues = DomainLogic.getDisplayValues()
html.fullTemplate.apply(displayValues)
}
}
private complexAction(authority: Authority)(f: User => Template => Request[AnyContent] => Result) =
Action { implicit request =>
(for {
user <- authorized(authority).right
_ <- Either.cond(validateToken, (), BadRequest).right
template <- Right(pjax).right
} yield f(user)(template)(request)).merge
}
def page1 = complexAction { user => template => request =>
// do something
Ok(template("result"))
}
def page2 = complexAction { user => template => request =>
// do something
Ok(template("result"))
}
このようにどんどん Action に対して操作の合成を行っていくことができます。
このモジュールの標準実装はステートフルな実装になっています。 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/play20-auth.git
cd play20-auth
play
run
- ブラウザで
http://localhost:9000/
にアクセス-
「Database 'default' needs evolution!」と聞かれるので
Apply this script now!
を押して実行します -
適当にログインします
アカウントは以下の3アカウントが登録されています。
Email | Password | Permission alice@example.com | secret | Administrator bob@example.com | secret | NormalUser chris@example.com | secret | NormalUser
-
このモジュールは Play2.x の Cache API を利用しています。
標準実装の Ehcache では、サーバを分散させた場合に正しく認証情報を扱えない場合があります。
サーバを分散させる場合には Memcached Plugin 等を利用してください。
このモジュールは Apache Software License, version 2 の元に公開します。
詳しくは LICENSE
ファイルを参照ください。