PlayFrameworkで独自の認証をplay2-authで実装するための自分向けチュートリアルです。独自というのは、よくありそうなMySQLなどのRDBMSを使う認証ではなく、社内にRESTful APIで認証を行うインターフェイスを備えている認証サーバがあり、そのプロトコルを使った認証を実装するということです。
ゴール
このチュートリアルが目指すゴールは下の目標を全て達成することです。
- play2-authを使って
- Ajax用のエンドポイント
/api/auth
を作成し、POSTリクエストを送り、認証できるようになる - Ajax用のエンドポイント
/api/user
を作成し、認可されたセッションで自分のアカウント情報が見れるようになる
なおこのチュートリアルの成果物はsuin/php-scala-migration-exampleにあげてあります。ご活用ください。
ベース
今回は次のコマンドで、play-scala
テンプレートから作ったPlayプロジェクトをベースに実装していきたいと思います。
activator new new-ui play-scala
build.sbtのセットアップ
build.sbtにplay2-auth
を追加します。
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では自分が定義したドメインオブジェクトを認証に組み込むことができるのがいいところです。なので、はじめにアカウントまわりのドメインオブジェクトを定義してしまいます。
最低限、ユーザを表現するクラスと、ロールを表現するクラスが必要になるので、ここではUser
とRole
を定義します。なお、クラス名は自由です。ここでのUser
はAccount
でもいいですし、Role
はUserType
でもかまいません。
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
という名前にします。
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を実装することになるので、継承しておきます。
package controllers
import jp.t2v.lab.play2.auth._
trait ProprietaryAuthConfig extends AuthConfig {
}
続いて、ProprietaryAuthConfig
を実装していきます。実装が必要なメンバは、下のコードで示すとおりです。
最後のauthorizationFailed
については非推奨のものですが、とりあえず、例外を投げるようにしておけばいいようです。将来的にこのメソッドは実装しなくてもよくなるかと思います。
また、ここで注意しておきたいのはoverride val idTag: ClassTag[Id]
にはimplicit
をつけてはいけないということです。僕の場合は、IDEのコード生成で勝手にimplicit
がついてしまい、無限ループ?になってしまいました…
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です。
認証はAjaxでやりたいと思うので、Ajax用のエンドポイントを作ります。ここでは、エンドポイントは下のURLに決めます。
POST http://localhost:9000/api/auth
Actionを作ります。ファイル名は、AuthApi.scalaとします。
package controllers
import play.api.mvc._
object AuthApi extends Controller {
def auth = Action {
Ok("Successfully authenticated!")
}
}
ルーティング設定もしておきます。
# 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.LoginLogout
とProprietaryAuthConfig
を継承します。加えてauth
アクションを実装していきます。
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
とします。
package controllers
import play.api.mvc._
object UserApi extends Controller {
def user = Action {
Ok("your account info will be shown here")
}
}
ルーティング設定も忘れずに追加します。
# 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します。
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でログイン・アカウント情報の表示ができるようにしてみたいと思います。