play framework2でログイン機能を実装してみる
scala conference in japan 2013までもう1ヶ月足らずですね! 復習がてら久々にplayを触ってみたので備忘録として残したいと思います。ちょっと長くなりすぎてしまいましたが。
目標
play framework2を使って、Webサービスのログイン機能(ユーザ登録とログイン/ログアウト、セッション)を実装したいと思います。 play 2.1が出たばかりなので今回使っていますが、2.1っぽいことはなにもしてないです。 viewにはtwitter bootstrapを、データの永続化にはH2 Databaseを使います。
環境
- OSX 10.8
- play framework2.1
- twitter bootstrap 2.2
プロジェクトの作成
適当なディレクトリで以下のコマンドを実行。 プロジェクト名はlogintestとします。
$ play new logintest What is the application name? [logintest] > Which template do you want to use for this new application? 1 - Create a simple Scala application 2 - Create a simple Java application > 1 OK, application scalamp is created. Have fun!
bootstrapの用意
まずbootstrapを使えるようにします。
logintest/public
がリソース置き場になるので、ここにbootstrapを持ってきます。
$ mv bootstrap logintest/public
次はbootstrapをviewに組み込むために、logintest/app/views/main.scala.htmlにbootstrapへのリンクを追記します。
@(title: String)(content: Html) <!DOCTYPE html> <html> <head> <title>@title - Login test</title> <link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")"> <link rel="stylesheet" media="screen" href="@routes.Assets.at("bootstrap/css/bootstrap.min.css")" > <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")"> <script src="@routes.Assets.at("javascripts/jquery-1.9.0.min.js")" type="text/javascript"></script> <script src="@routes.Assets.at("bootstrap/js/bootstrap.min.js")" type="text/javascript"></script> </head> <body> @content </body> </html>
これで各viewにmainを組み込めばbootstrapが適用された状態になります。
ユーザ登録画面
新規にlogintest/app/views/signup.scala.htmlを作成します。
@(form: Form[(String, String, (String, String))]) @import views.BootstrapHelper._ @main("Sign up") { <legend>Sign up</legend> @helper.form( routes.Application.register, 'class -> "form-horizontal" ) { <fieldset> @if(form.hasErrors) { <div class="alert alert-block alert-error"> There are problems. </div> } @helper.inputText( form("email"), 'type -> "email", 'placeholder -> "john@example.com", '_label -> "Email Address", '_help -> "This is used for your login.", '_error -> form.error("email") ) @helper.inputText( form("name"), 'placeholder -> "John Smith", '_label -> "Your name" ) @helper.inputPassword( form("password.main"), 'placeholder -> "secret", '_label -> "Passowrd", '_error -> form.error("password") ) @helper.inputPassword( form("password.confirm"), 'placeholder -> "secret", '_label -> "Confirm password", '_help -> "Input password again." ) <div class="control-group"> <div class="controls"> <button type="submit" id="signupbutton" class="btn">Create an account</button> </div> </div> </fieldset> } }
いきなり長くなってしまいましたが難しいことはしてません。 まず先頭では@(form: Form[(String, String, (String, String))])で引数としてユーザ登録フォームを定義してます。
@import文ではフォームヘルパーを読み込んでいます。 フォームヘルパーは@helper.inputText()などを自動的にhtmlに展開してくれる便利な機能です。 通常は@import helper._や@import helper.twitterBootstrap._など定義済みのヘルパーを利用しますが、 今回はbootstrapのHorizontal formのレイアウトにしたいため自作しています。
フォームヘルパーの自作するために、こちらの記事を参考に2つのファイルを作成しました。コードは同じなので割愛します。
- bootstrapField.scala.html
- bootstrapHelper.scala
あとはエラー時の処理として@if(form.hasErrors)によるエラーメッセージの表示と、入力に問題があったときにエラーを表示させたいところに'_error -> form.error("email")などを入れています。どの場合にエラーになるかはcontrollerで実装します。
ログイン画面
logintest/app/views/login.scala.htmlとして新規作成します。
@(form: Form[(String, String)])(implicit flash: Flash) @import views.BootstrapHelper._ @main("Login") { <legend>Login</legend> @helper.form( routes.Application.authenticate, 'class -> "form-horizontal" ) { <fieldset> @if(form.hasErrors) { <div class="alert alert-block alert-error"> There are problems. </div> } @flash.get("success").map { message => <div class="alert alert-block alert-info"> @message </div> } @helper.inputText( form("email"), 'type -> "email", 'placeholder -> "john@example.com", '_label -> "Email Address", '_error -> form.globalError ) @helper.inputPassword( form("password"), 'placeholder -> "secret", '_label -> "Passowrd", '_error -> form.globalError ) <div class="control-group"> <div class="controls"> <button type="submit" id="loginbutton" class="btn">Login</button> </div> </div> } </fieldset> <div class="well well-small"> Don't you have an account? <a href="@routes.Application.signup">Let's sign up here</a>. </div> }
やっていることとしてはユーザ登録とほとんど変わりませんが、こちらは引数に(implicit flash: Flash)が増えています。この例ではログオフ時にメッセージを渡すために利用しています。
登録完了画面
確認用に登録情報の表示と、ログイン画面へのリンクを実装します。 logintest/app/views/registered.scala.htmlとして新規作成します。
@(user: User) @import views.BootstrapHelper._ @main("Registration complete!") { <legend>Welcome!</legend> <dl class="dl-horizontal"> <dt>Email Address</dt> <dd>@user.email</dd> </dl> <dl class="dl-horizontal"> <dt>Your name</dt> <dd>@user.name</dd> </dl> <div class="well well-small"> <a href="@routes.Application.logout">logout</a> </div> }
トップ画面
ほぼほぼデフォルトのまんまですが、ログアウトのリンクが追加されています。
@(message: String) @main("Welcome to Play 2.1") { @play20.welcome(message) <div class="well well-small"> <a href="@routes.Application.logout">Logout</a> </div> }
viewは以上です。フォームヘルパーの使い方がわかれば特に困るところはありません(たぶん)。
controller
ログイン機能しか実装しないので基本的にlogintest/app/controllers/Application.scalaにすべて書きます。
package controllers import play.api._ import play.api.mvc._ import play.api.data._ import play.api.data.Forms._ import models._ import views._ import controllers._ object Application extends Controller with Secured { // ログインフォーム val loginForm = Form( tuple( "email" -> nonEmptyText, "password" -> nonEmptyText ) verifying ("Invalid email or password", result => result match { case (email, password) => User.authenticate(email, password).isDefined }) ) // トップページ def index = IsAuthenticated { email => _ => Ok(views.html.index("Your new application is ready.")) } // ログインページ def login = Action { implicit request => Ok(html.login(loginForm)) } // ユーザ認証 def authenticate = Action { implicit request => loginForm.bindFromRequest.fold( formWithErrors => BadRequest(html.login(formWithErrors)), user => Redirect(routes.Application.index).withSession("email" -> user._1) ) } // ログアウト def logout = Action { Redirect(routes.Application.login).withNewSession.flashing( "success" -> "You've been logged out" ) } // 登録フォーム val signupForm = Form( tuple( "email" -> nonEmptyText.verifying( "This email address is already registered.", email => User.findByEmail(email).isEmpty ), "name" -> nonEmptyText, "password" -> tuple( "main" -> nonEmptyText, "confirm" -> nonEmptyText ).verifying( "Password is not match.", password => password._1 == password._2 ) ) ) // ユーザ登録ページ def signup = Action { Ok(html.signup(signupForm)) } // ユーザ登録 def register = Action { implicit request => signupForm.bindFromRequest.fold( errors => BadRequest(html.signup(errors)), form => { val user = User(form._1, form._2, form._3._1) User.create(user) Ok(html.registered(user)) } ) } }
controllerの基本的な書き方はActionに処理を渡したものを関数として定義します。 ただしこの例ではindexのみIsAuthenticatedに処理を渡しています。 これは自分で定義したSecuredトレイトで実装されているメソッドで、ログイン中の場合のみトップページの表示を許可しています。
ログインフォームやユーザ登録フォームはForm型で定義してます。 要素に対してverifyingを使ってエラーチェックを行ったりすることができます。 空文字のチェックはnonEmptyTextというフォームデータマッピングによって行っています。 フォームデータマッピングについてはこちらの記事が詳しいです。
ユーザ認証用トレイト
認証機能を実現するためにSecuredトレイトというものを作ります。 logintest/app/controller/Secured.scalaを新規作成します。
package controllers import play.api._ import play.api.mvc._ trait Secured { private def email(request: RequestHeader) = request.session.get("email") // 未認証時のリダイレクト先 private def onUnauthorized(request: RequestHeader) = Results.Redirect(routes.Application.login) // Actionに認証をかませてラップ def IsAuthenticated(f: => String => Request[AnyContent] => Result) = Security.Authenticated(email, onUnauthorized) { user => Action(request => f(user)(request)) } }
Securedトレイトではセッションからログイン時に使用しているメールアドレスが取得できるかどうかによってログイン中かそうでないかを判定しています。 ログイン中の場合はIsAuthenticatedで渡された関数を実行しますが、ログインしていない場合はログイン画面にリダイレクトしています。
modelの実装
loginにおける認証部分やregisterでユーザ登録を行うために、コンパニオンオブジェクトであるlogintest/app/models/User.scalaを作成します。
package models import play.api.db._ import play.api.Play.current import anorm._ import anorm.SqlParser._ case class User(email: String, name: String, password: String) object User { val simple = { get[String]("user.email") ~ get[String]("user.name") ~ get[String]("user.password") map { case email ~ name ~ password => User(email, name, password) } } def findById(id: Long): Option[User] = { DB.withConnection { implicit c => SQL("select * from user where user_id = {id}").on( 'id -> id ).as(User.simple.singleOpt) } } def findByEmail(email: String): Option[User] = { DB.withConnection { implicit c => SQL("select * from user where email = {email}").on( 'email -> email ).as(User.simple.singleOpt) } } def findAll: Seq[User] = { DB.withConnection { implicit c => SQL("select * from user").as(User.simple *) } } def authenticate(email: String, password: String): Option[User] = { DB.withConnection { implicit c => SQL( """ select * from user where email = {email} and password = {password} """ ).on( 'email -> email, 'password -> password ).as(User.simple.singleOpt) } } def create(user: User): User = { DB.withConnection { implicit c => SQL( """ insert into user ( email, name, password ) values ( {email}, {name}, {password} ) """ ).on( 'email -> user.email, 'name -> user.name, 'password -> user.password ).executeUpdate() user } } }
今回は簡単にするために登録情報はemail、name、passwordのみとしており、ケースクラスで定義しています。 またval simpleはレコードをパースするために定義し、複数レコード返ってくる可能性のある場所ではsingleOptを使ってOption型を一つを取り出しています。
スキーマの定義
Userで発行しているSQLが動くようなスキーマが必要です。 スキーマはlogintest/conf/evolutions/default/1.sqlとして定義します。
# --- !Ups CREATE SEQUENCE user_id_seq; CREATE TABLE user ( user_id integer DEFAULT nextval('user_id_seq') PRIMARY KEY , email varchar(255) NOT NULL UNIQUE, name varchar(255) NOT NULL, password varchar(255) NOT NULL, ); # --- !Downs DROP TABLE if exists user; DROP SEQUENCE if exists user_id_seq;
コメントのupsとdownの部分はそれぞれjunitでいうとこのsetupとteardownみたいなイメージです。 同じファイル中に書いていてもそれぞれ適切なタイミングで呼び出してくれます。
DBドライバの設定
今回はH2を使用しているので、デフォルトドライバとしてlogintest/conf/application.confに以下を追記します。
db.default.driver=org.h2.Driver db.default.url="jdbc:h2:mem:play"
ルーティング
ルーティングはlogintest/conf/routesで設定します。 今回はログイン関係のもののみ追加しました。
# Routes # This file defines all application routes (Higher priority routes first) # ~~~~ # Home page GET / controllers.Application.index # Authentication GET /login controllers.Application.login POST /login controllers.Application.authenticate GET /logout controllers.Application.logout GET /signup controllers.Application.signup POST /signup controllers.Application.register # Map static resources from the /public folder to the /assets URL path GET /assets/*file controllers.Assets.at(path="/public", file)
実行!
いざ実行です。 logintestディレクトリで以下のようにplayコマンドを実行し、runでサーバを立ち上げます。
$ play play! 2.1.0 (using Java 1.6.0_37 and Scala 2.10.0), http://www.playframework.org > Type "help play" or "license" for more information. > Type "exit" or use Ctrl+D to leave this console. [logintest] $ run
問題がなければhttp://127.0.0.1:9000にアクセスしてみましょう! ちなみにエラーが起きた場合も、runした状態でブラウザの更新を行うと自動的に再ビルドが走るので、修正しながらコード書くのも楽です。
まず最初の起動ではDBの初期化を行うために、Apply this script nowを押します。
するとログイン画面が表示されました。 これは、トップページへのアクセスはユーザ認証が必要なように設定したため、Securedトレイトの実装通りの挙動です。
アカウントがないのでユーザ登録画面に進みます。
試しに空白のままユーザ登録してみると、たくさんエラーが表示されました。
次は適当に項目を埋めて登録します。するとみごと登録完了画面が表示されました。
最後に登録した情報を使ってログインしてみます。
これで今度こそトップ画面の表示ができました。 ばんざーい。
さいごに
非常に長くなってしまいましたが、とりあえず一通り実装の流れを紹介してみました。 だいたいサンプルを元に作っているんですが、やりたいことを実現する方法を知るまでにちょっと困ったので、誰かの助けになれば幸いです。 時間がなくてあんまり触れてませんが、次はもうちょっとplay2.1っぽいことをしたいですね。