概要
ほとんどのWebアプリケーションは、今操作しているユーザーを識別するためにセッションの管理機能を持っています。
play2でセッション管理といえば play2-auth
ですよね。
ということで、試してみます。
(認証と認可で記事を分けることにしました。今回は認証編です。)
(ここではOAuth2.0対応は触れず、自前で認証機能を持つ場合を考えます。)
この記事のゴール
- 認証・認可の大まかな流れを理解する
- play2-authで認証部分まで作り動作確認する
- ログインできてcookieが付与されているところまで。
- ログイン情報が正しいかは認可のフェーズで行うため割愛。
環境
- Play 2.5
- play2-auth 0.14.2
全体のコードは以下。
認証・認可の難しいところ
まず、authという単語を見た時に、
- Authentication - 認証
- Authorization - 認可
があって、ここで既にややこしいです。
また、認証・認可では管理するものが多く、用意するのも大変だけどライブラリ任せにしにくいところでもあります。
個別に設定する必要があるものとして、
- ユーザー情報管理
- UserIdやpassword、権限やその他ユーザー特有の情報
- サーバー側でのセッショントークンの管理
- サーバーのスケーリングを考えるとセッション管理も外で管理したくなります。
- クライアントの識別に使うためのトークンのやりとりの方法
- 普通はブラウザなのでcookie経由でやりとりします。
というものが上げられます。
play2-authではどのようにこれらを扱えるかも合わせて見てみましょう。
実装の流れ
- ユーザーを定義する
- IDとpassを照合するServiceを用意する
- 認証・認可の際の各種イベントを実装する
- 上記を用いてcontrollerを実装する
という流れになります。
ユーザーを定義する
とりあえず、idとrole(後で認可の際に使う)をUserクラスとして定義します。
case class MyUser(
id: String,
role: MyRole)
sealed trait MyRole
case object Administrator extends MyRole
case object NormalUser extends MyRole
IDとpassを照合するServiceを用意する
次に、上記の定義を使って認証に使う際のServiceを用意します。
ここでは、後で動作確認するために2人のUserをベタ書きで登録してあります。
(ここではベタ書きしていますが、実運用ではDBに入れる形になるでしょう。)
このServiceでは、ログイン時に認証に使うためのauthenticateメソッドと、IDから権限などのユーザー情報を引いてくるためのuserOfIdメソッドを用意します。
//後でDIできるように差し替えるが、とりあえずObjectで。。。
object AuthService {
//本当はDBに繋ぐ
private val users = List[(MyUser, String)](
(MyUser("admin", Administrator), "pass1"),
(MyUser("normal", NormalUser), "pass2")
)
def userOfId(userId: String): Option[MyUser] = {
//本当はDBに繋ぐ
users.find(_._1.id == userId).map(_._1)
}
def authenticate(userId: String, password: String): Option[MyUser] = {
//本当はDBに繋ぐ
users.find(u => u._1.id == userId && u._2 == password).map(_._1)
}
}
認証・認可の際の各種イベントを実装する
play2-authの肝となる部分です。
認証・認可の各イベントが起きた時にどう振る舞うかを実装することで、裏でplay2-authがよろしくやってくれる部分になります。
今回は簡単なものを用意します。(認可部分は後でやるのでさらに簡易にしてあります。)
先にコードを見ましょう。
import jp.t2v.lab.play2.auth._
import play.api.mvc.{RequestHeader, Result, Results}
import utils._
import scala.concurrent.{ExecutionContext, Future}
import scala.reflect.{ClassTag, classTag}
trait AuthConfigImpl extends AuthConfig {
override type Id = String
override type User = MyUser
/**
* 認可のために使う権限を表現した型。
*/
override type Authority = MyRole
/**
* 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時間
override def resolveUser(id: Id)(implicit context: ExecutionContext): Future[Option[User]] = {
Future.successful(AuthService.userOfId(id))
}
override def loginSucceeded(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
Future.successful(Results.Ok("login success"))
}
override def logoutSucceeded(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
Future.successful(Results.Ok("logout success"))
}
override def authenticationFailed(request: RequestHeader)(implicit context: ExecutionContext): Future[Result] = {
Future.successful(Results.Unauthorized("Unauthorized"))
}
override def authorizationFailed(request: RequestHeader, user: User, authority: Option[Authority])(implicit context: ExecutionContext): Future[Result] = {
Future.successful(Results.Forbidden("No permission"))
}
override def authorize(user: User, authority: Authority)(implicit context: ExecutionContext): Future[Boolean] = {
//Authorizationの時に書く
Future.successful(true)
}
/**
* セッショントークンの管理をします。デフォルトではPlayのCache APIを用いています。
*/
override lazy val idContainer: AsyncIdContainer[Id] = AsyncIdContainer(new CacheIdContainer[Id])
/**
* tokenのやりとりをどのように行うかを定義します。
* デフォルトではCookieによるトークンの授受が実装されています。
*/
override lazy val tokenAccessor: TokenAccessor = new CookieTokenAccessor(
cookieMaxAge = Some(sessionTimeoutInSeconds)
)
}
実装するものが多いですね。。。
まず、Id/User/Authority/idTag あたりまでは、認証・認可に自前で用意した型を使えるように型への参照を定義しています。
次にloginSucceededからauthorizationFailedまでは、各種アクションの結果に応じて、どうクライアントに結果を返すかを定義しています。
今回は省略のため、statusCodeと最小限のコメントだけ添えることにします。
resolveUser/sessionTimeoutInSeconds/authorize あたりは見たら分かると思うので省略します。。。
idContainerでは、セッショントークンの管理方法を定義しています。デフォルトではPlayのCacheAPIが使われています。
(cacheやstaticな呼び出しになっててテストしづらいので直したい。。。というPRが上がってます。)
https://github.com/t2v/play2-auth/pull/178
tokenAccessorでは、tokenのやりとり方法を定義しています。デフォルトではcookieを使う形になっています。
(ここでもデフォルト実装のcookieSecureOptionの判別にPlayを呼んでるところがありますね。。。)
少々複雑ですが、これらを自前の環境に合うように実装すれば使えるようになっています。便利!
上記を用いてControllerを実装する
ここでは、先ほどのAuthConfigImplとLoginLogoutという認証用のtraitを実装することで、Controllerを実装しています。
受け取ったuserIdとpasswordでユーザー判定をして、問題なければgotoLoginSucceededにUserのIdを渡すことで認証が完了します。
ログアウトするときは、gotoLogoutSucceededを呼ぶだけです。
(なお、ここでは簡略のためidとpassをurlのパラメータに入れるという無精な方法を撮っています。くれぐれも真似しないよう。。。)
import javax.inject.{Inject, Singleton}
import akka.actor.ActorSystem
import jp.t2v.lab.play2.auth.LoginLogout
import play.api.mvc._
import utils.{AuthService, MyUser}
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class AuthenticateController @Inject() (actorSystem: ActorSystem) extends Controller with LoginLogout with AuthConfigImpl {
implicit val myExecutionContext: ExecutionContext = actorSystem.dispatcher
private def authenticate(userId: String, password: String): Either[Result, MyUser] = {
AuthService.authenticate(userId, password) match {
case None => Left(Unauthorized("authentication failed"))
case Some(user) => Right(user)
}
}
def login(userId: String, password: String) = Action.async { implicit request =>
authenticate(userId, password) match {
case Left(r) => Future{r}
case Right(user) => gotoLoginSucceeded(user.id)
}
}
def logout() = Action.async { implicit request =>
gotoLogoutSucceeded
}
}
GET /authentication/login/:userId/:password controllers.auth.AuthenticateController.login(userId: String, password: String)
GET /authentication/logout controllers.auth.AuthenticateController.logout
以上を実装した上で、
http://localhost:9000/authentication/login/admin/pass1
などへアクセスすると login success が返ってきます。
このとき、cookieが付与されていることが確認できるはずです。
IDやpassを間違えると authentication failedが返ってきます。
まとめ
なかなか難しいですが、そもそも認証・認可が難しいので仕方ないですね。。。
play2-authを使うとどこを実装すればいいのかの指針を教えてくれる感じでとても良いと思います。
次は認可について書きます。
・・・
ところで、play2-authのコードでCryptoが使われているのを見ました。
https://www.playframework.com/documentation/2.5.x/Migration25#Crypto-Deprecated
全体的にまだ2.5への対応が途中な気がするので、これはPRチャンスかも!