Skip to content

Latest commit

 

History

History
520 lines (371 loc) · 18.5 KB

README.ja.md

File metadata and controls

520 lines (371 loc) · 18.5 KB

Play2.x module for Authentication and Authorization Build Status

これは Play2.x のアプリケーションに認証/認可の機能を手軽に組み込むためのモジュールです。

対象

このモジュールは Play2.x の __Scala__版を対象としています。 Java版には Deadbolt 2 というモジュールがありますので こちらも参考にして下さい。

Play2.0.4 及び Play2.1-RC1 で動作確認をしています。

動機

安全性

標準で提供されている 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.6"
    

For example: Build.scala

  val appDependencies = Seq(
    "jp.t2v" %% "play21.auth" % "0.6"
  )

  val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA)

このモジュールはシンプルな Scala ライブラリとして作成されています。 play.plugins ファイルは作成する必要ありません。

使い方

  1. 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 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")
    
    }
  2. 次にログイン、ログアウトを行う 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)
        )
      }
    
    }
  3. 最後は、好きな 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を返す

通常のアクセスで認証が失敗した場合にはログイン画面にリダイレクトさせたいけれども、 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)
  }
}

他のAction操作と合成する

例えば、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 に対して操作の合成を行っていくことができます。

Stateless

このモジュールの標準実装はステートフルな実装になっています。 Play framefork が推奨するステートレスなポリシーを尊重したくはあるのですが、 ステートレスにすると次のようなセキュリティリスクが存在するため、標準では安全側に倒してあります。

例えば、インターネットカフェなどでサービスにログインし、 ログアウトするのを忘れて帰宅してしまった、といった場合。 ステートレスではその事実に気付いても即座にそのSessionを無効にすることができません。 標準実装ではログイン時に、それより以前のSessionを無効にしてます。 したがってこの様な事態に気付いた場合、即座に再ログインすることでSessionを無効化することができます。

このようなリスクを踏まえ、それでもステートレスにしたい場合、 以下のように設定することでステートレスにすることができます。

trait AuthConfigImpl extends AuthConfig {

  // 他の設定省略

  override lazy val idContainer: IdContainer[Id] = new CookieIdContainer[Id]

}

IdContainer は SessionID および UserID を紐付ける責務を負っています。 この実装を切り替えることで、例えば RDBMS に認証情報を登録するといった事も可能です。

なお、CookieIdContainer ではSessionタイムアウトは未サポートとなっています。

サンプルアプリケーション

  1. git clone https://github.com/t2v/play20-auth.git
  2. cd play20-auth
  3. play
  4. run
  5. ブラウザで http://localhost:9000/ にアクセス
    1. 「Database 'default' needs evolution!」と聞かれるので Apply this script now! を押して実行します

    2. 適当にログインします

      アカウントは以下の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 ファイルを参照ください。