LoginSignup
26
26

More than 5 years have passed since last update.

チュートリアル: play2-authでPlayに独自の認証を実装する

Last updated at Posted at 2015-05-26

PlayFrameworkで独自の認証をplay2-authで実装するための自分向けチュートリアルです。独自というのは、よくありそうなMySQLなどのRDBMSを使う認証ではなく、社内にRESTful APIで認証を行うインターフェイスを備えている認証サーバがあり、そのプロトコルを使った認証を実装するということです。

ゴール

このチュートリアルが目指すゴールは下の目標を全て達成することです。

  1. play2-authを使って
  2. Ajax用のエンドポイント/api/authを作成し、POSTリクエストを送り、認証できるようになる
  3. Ajax用のエンドポイント/api/userを作成し、認可されたセッションで自分のアカウント情報が見れるようになる

なおこのチュートリアルの成果物はsuin/php-scala-migration-exampleにあげてあります。ご活用ください。

ベース

今回は次のコマンドで、play-scalaテンプレートから作ったPlayプロジェクトをベースに実装していきたいと思います。

activator new new-ui play-scala

build.sbtのセットアップ

build.sbtにplay2-authを追加します。

build.sbt
name := """new-ui"""

version := "1.0-SNAPSHOT"

lazy val root = (project in file(".")).enablePlugins(PlayScala)

scalaVersion := "2.11.6"

libraryDependencies ++= Seq(
  jdbc,
  anorm,
  cache,
  ws,
  "jp.t2v" %% "play2-auth"      % "0.13.2",
  "jp.t2v" %% "play2-auth-test" % "0.13.2" % "test"
)

activatorを起動して、runを実行するとライブラリがダウンロードされれば、build.sbtの設定はOKです。

activator
> run

Ctrl + D → Ctrl + Cの順でキーを押下して、activatorを停止します。

認証の設定(AuthConfig)を実装する

ここからScalaコードを書きたいと思います。僕は、IntelliJを使うので、IDE用のプロジェクトファイルを生成しておきます。

activator
> gen-idea

ライブラリがドメインオブジェクトのシグネチャを決めてしまうライブラリと違い、play2-authでは自分が定義したドメインオブジェクトを認証に組み込むことができるのがいいところです。なので、はじめにアカウントまわりのドメインオブジェクトを定義してしまいます。

最低限、ユーザを表現するクラスと、ロールを表現するクラスが必要になるので、ここではUserRoleを定義します。なお、クラス名は自由です。ここでのUserAccountでもいいですし、RoleUserTypeでもかまいません。

app/models/account/package.scala
package models

package object account {

  case class User(
                   id: String,
                   username: String,
                   role: Role)


  sealed trait Role

  object Role {
    case object Administrator extends Role
    case object NormalUser extends Role
  }
}

また、認証サーバとやりとりして、Userオブジェクトをひっぱてくるサービスを作っておきます。ここではAccountServiceという名前にします。

app/services/AccountService.scala
package services

import models.account.User

// 本当は認証サーバにRESTで問い合わせる
object AccountService {
  private val users = List[User](
    User("6E7D8ACD-C36E-4ACC-87B7-5EC35CBA1423", "admin", models.account.Role.Administrator),
    User("D4EAF402-54CC-469A-AACB-66EF0A0CB0AF", "alice", models.account.Role.Administrator)
  )

  def userOfId(id: String): Option[User] = {
    users.find(_.id == id)
  }

  def authenticate(username: String, password: String): Option[User] = {
    users.find(_.username == username)
  }
}

自前の認証を作るためにtraitを1つ作成します。ここではProprietaryAuthConfig.scalaという名前にします。このtraitはjp.t2v.lab.play2.auth.AuthConfigを実装することになるので、継承しておきます。

app/controllers/ProprietaryAuthConfig.scala
package controllers

import jp.t2v.lab.play2.auth._

trait ProprietaryAuthConfig extends AuthConfig {

}

続いて、ProprietaryAuthConfigを実装していきます。実装が必要なメンバは、下のコードで示すとおりです。

最後のauthorizationFailedについては非推奨のものですが、とりあえず、例外を投げるようにしておけばいいようです。将来的にこのメソッドは実装しなくてもよくなるかと思います。

また、ここで注意しておきたいのはoverride val idTag: ClassTag[Id]にはimplicitをつけてはいけないということです。僕の場合は、IDEのコード生成で勝手にimplicitがついてしまい、無限ループ?になってしまいました… :sweat:

app/controllers/ProprietaryAuthConfig.scala
package controllers

import jp.t2v.lab.play2.auth._
import play.api.mvc.{Result, RequestHeader}
import play.api.mvc.Results.{Redirect, Forbidden, Unauthorized}
import scala.concurrent.{Future, ExecutionContext}
import scala.reflect.{ClassTag,classTag}
import models.account.Role._
import services.AccountService

trait ProprietaryAuthConfig extends AuthConfig {

  /**
   * ユーザを識別するための型。
   * `String`, `Int`, `Long` など。
   */
  override type Id = String

  /**
   * ユーザを表現する型。modelなどに定義したclassの型を指定する。
   */
  override type User = models.account.User

  /**
   * 認可のために使う権限を表現した型。
   */
  override type Authority = models.account.Role

  /**
   * A `ClassTag` is used to retrieve an id from the Cache API.
   * Use something like this:
   */
  override val idTag: ClassTag[Id] = classTag[Id]

  /**
   * セッションがタイムアウトするまでの時間(秒)
   */
  override def sessionTimeoutInSeconds: Int = 3600 // 1時間

  /**
   * `Id`からユーザを探す方法。
   */
  override def resolveUser(id: Id)(implicit context: ExecutionContext): Future[Option[User]] = {
    Future.successful(AccountService.userOfId(id))
  }

  /**
   * ログインできたらどうするか?
   */
  override def loginSucceeded(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Redirect(routes.Application.index))
  }

  /**
   * ログアウトしたらどうするか?
   */
  override def logoutSucceeded(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Redirect(routes.Application.index))
  }

  /**
   * 認証に失敗したらどうするか?
   */
  override def authenticationFailed(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Unauthorized("Bad credentials"))
  }

  /**
   * 権限がないアクセスはどうするか?
   */
  override def authorizationFailed(request: RequestHeader, user: User, authority: Option[Authority])(implicit context: ExecutionContext): Future[Result] = {
    Future.successful(Forbidden("No permission"))
  }

  /**
   * ユーザが権限を持っているかを判定する関数。
   */
  override def authorize(user: User, authority: Authority)(implicit context: ExecutionContext): Future[Boolean] = Future.successful {
    (user.role, authority) match {
      case (Administrator, _)       => true // AdminならどんなActionでも全権限を開放
      case (NormalUser, NormalUser) => true // ユーザがNormalUserで、ActionがNormalUserなら権限あり。もしActionがAdminだけなら権限なしになる。
      case _                        => false
    }
  }

  override def authorizationFailed(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = throw new AssertionError("don't use")
}

ひとまずコンパイルしてみます。ここで、コンパイルエラーがでなければOKです。

> compile

ログインのAPIを実装する(Authentication)

それではログインのAPIを作っていきます。このセクションは認証(Authentication)を実装するチュートリアルになります。

その前に、ここからHTTPでレスポンスを確認しながら開発していきたいので、HTTPサーバを起動します。

> run

http://localhost:9000/ を開いてトップページが表示されれば、起動はOKです。

Welcome_to_Play.png

認証はAjaxでやりたいと思うので、Ajax用のエンドポイントを作ります。ここでは、エンドポイントは下のURLに決めます。

POST http://localhost:9000/api/auth

Actionを作ります。ファイル名は、AuthApi.scalaとします。

app/controllers/AuthApi.scala
package controllers

import play.api.mvc._

object AuthApi extends Controller {

  def auth = Action {
    Ok("Successfully authenticated!")
  }

}

ルーティング設定もしておきます。

conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.Application.index

# Auth APIs
POST    /api/auth                   controllers.AuthApi.auth

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.at(path="/public", file)

Playのサーバが立ち上がったままだと思うので、試しにPOSTリクエストを出してみます。リクエストを出すにはHTTPまわりのデバッグに便利なhttpieを使っていきます。

$ http -v  POST localhost:9000/api/auth
POST /api/auth HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 0
Host: localhost:9000
User-Agent: HTTPie/0.8.0



HTTP/1.1 200 OK
Content-Length: 27
Content-Type: text/plain; charset=utf-8

Successfully authenticated!

レスポンスが200 OKとなっていればルーティングの設定までがうまくできています。

ではログインの処理を実装していきます。先ほどのAuthApiコントローラでjp.t2v.lab.play2.auth.LoginLogoutProprietaryAuthConfigを継承します。加えてauthアクションを実装していきます。

app/controllers/AuthApi.scala
package controllers

import play.api.mvc._
import play.api.libs.json._
import jp.t2v.lab.play2.auth.LoginLogout
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import services.AccountService

object AuthApi extends Controller with LoginLogout with ProprietaryAuthConfig {

  // POSTで渡ってくるデータの形式
  case class AuthData(username: String, password: String)

  // JSONをパースできるようにするための宣言
  implicit val authDataReads = Json.reads[AuthData]

  def auth = Action.async(BodyParsers.parse.json) { implicit request =>
    request.body.validate[AuthData].fold(
      errors => {
        Future.successful(BadRequest(Json.obj("message" -> JsError.toFlatJson(errors))))
      },
      data => {
        AccountService.authenticate(data.username, data.password) match {
          case None => Future.successful(Unauthorized(Json.obj("message" -> "authentication failed")))
          case Some(user) => gotoLoginSucceeded(user.id)
        }
      }
    )
  }
}

ここまで実装したら、POST /api/authにて認証できるか試してみます。

$ http -v POST localhost:9000/api/auth username=alice password=secret
POST /api/auth HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 43
Content-Type: application/json; charset=utf-8
Host: localhost:9000
User-Agent: HTTPie/0.8.0

{
    "password": "secret",
    "username": "alice"
}

HTTP/1.1 303 See Other
Content-Length: 0
Location: /
Set-Cookie: PLAY2AUTH_SESS_ID=f25da3108fa744895a79b135bb9b75734dfb18fe_5hdnvje'cvef)5e'f964b7i~l)6)x5hmex1e~w.5v_kdv~2hwy)qc55nwzs4wf6; Max-Age=3600; Expires=Tue, 26 May 2015 08:48:46 GMT; Path=/; HTTPOnly

ステータスコードが303で、Set-Cookieヘッダーが出ていれば成功です。

ついでに認証に失敗する場合も確認します。

$ http -v POST localhost:9000/api/auth username=bob password=secret
POST /api/auth HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 41
Content-Type: application/json; charset=utf-8
Host: localhost:9000
User-Agent: HTTPie/0.8.0

{
    "password": "secret",
    "username": "bob"
}

HTTP/1.1 401 Unauthorized
Content-Length: 35
Content-Type: application/json; charset=utf-8

{
    "message": "authentication failed"
}

認証に失敗したら、401になるように実装していたので、期待通り401になるはずです。

アカウント情報を取得できるAPIを追加する(Authorization)

認証ができるようになったので、今度はログイン中のユーザが、自分のアカウント情報をAPI経由で取得できるようにしたいと思います。ここからは、認可(Authorization)のチュートリアルになります。

APIのエンドポイントは下記のURLにします。

GET /api/user

さっそくControllerを作っていきます。ファイル名はUserApi.scalaとします。

app/controllers/UserApi.scala
package controllers

import play.api.mvc._

object UserApi extends Controller {
  def user = Action {
    Ok("your account info will be shown here")
  }
}

ルーティング設定も忘れずに追加します。

conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.Application.index

# Auth APIs
POST    /api/auth                   controllers.AuthApi.auth

# User APIs
GET     /api/user                   controllers.UserApi.user

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.at(path="/public", file)

ひとまずルーティングだけでもうまくできているか確認してみます。200でレスポンスが来ればOKです。

$ http localhost:9000/api/user
HTTP/1.1 200 OK
Content-Length: 36
Content-Type: text/plain; charset=utf-8

your account info will be shown here

では、このUserApiコントローラのuserアクションを認可されたユーザだけが見れるようにしてみます。認可の機能を使うには、jp.t2v.lab.play2.auth.AuthElementと、自作したProprietaryAuthConfigをコントローラにmix-inします。

app/controllers/UserApi.scala
package controllers

import jp.t2v.lab.play2.auth.AuthElement
import models.account.Role.NormalUser
import play.api.mvc._
import play.api.libs.json._

object UserApi extends Controller with AuthElement with ProprietaryAuthConfig {
  def user = StackAction(AuthorityKey -> NormalUser) { implicit request =>
    val user = loggedIn
    Ok(Json.obj("id" -> user.id, "username" -> user.username))
  }
}

このStackActionメソッドは、StackAction(AuthorityKey -> ロール名)という形でアクセス制限をすることができます。loggedInメソッドは、現在セッションのユーザオブジェクトが返ってきます。

ここまで実装できた、認可のテストをしてみます。httpieは--session=nameオプションで任意のnameのセッションを保持することができます。次のコマンドでログインします。

$ http --session=alice POST localhost:9000/api/auth username=alice password=secret
HTTP/1.1 303 See Other
Content-Length: 0
Location: /
Set-Cookie: PLAY2AUTH_SESS_ID=af8da1226ff979822d26ed9025c0b8b198fcf282*vl'4jrg!rk_77*cdxu174m('us~*wgu9rsx4o(ik9'e39hedjvs9ntyh2(fvsac; Max-Age=3600; Expires=Tue, 26 May 2015 09:29:53 GMT; Path=/; HTTPOnly

次に、セッションを保ったまま/api/userにアクセスしてみます。

$ http -v --session=alice localhost:9000/api/user
GET /api/user HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Cookie: PLAY2AUTH_SESS_ID=af8da1226ff979822d26ed9025c0b8b198fcf282*vl'4jrg!rk_77*cdxu174m('us~*wgu9rsx4o(ik9'e39hedjvs9ntyh2(fvsac
Host: localhost:9000
User-Agent: HTTPie/0.8.0



HTTP/1.1 200 OK
Content-Length: 64
Content-Type: application/json; charset=utf-8
Set-Cookie: PLAY2AUTH_SESS_ID=af8da1226ff979822d26ed9025c0b8b198fcf282*vl'4jrg!rk_77*cdxu174m('us~*wgu9rsx4o(ik9'e39hedjvs9ntyh2(fvsac; Max-Age=3600; Expires=Tue, 26 May 2015 09:32:19 GMT; Path=/; HTTPOnly

{
    "id": "D4EAF402-54CC-469A-AACB-66EF0A0CB0AF",
    "username": "alice"
}

レスポンスが200で、aliceのアカウント情報が出ていれば成功です。

加えて、ログインしていないユーザでリクエストを出してみましょう。

$ http -v localhost:9000/api/user
GET /api/user HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:9000
User-Agent: HTTPie/0.8.0



HTTP/1.1 401 Unauthorized
Content-Length: 15
Content-Type: text/plain; charset=utf-8

Bad credentials

レスポンスが401になっていれば成功です。このレスポンスはProprietaryAuthConfig.authenticationFailedで設定した内容になっているはずです。

つづく

つづきとしてUIを実装してAjaxでログイン・アカウント情報の表示ができるようにしてみたいと思います。

26
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
26