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を押します。

f:id:akiomik:20130207204912p:plain

するとログイン画面が表示されました。 これは、トップページへのアクセスはユーザ認証が必要なように設定したため、Securedトレイトの実装通りの挙動です。

f:id:akiomik:20130207204443p:plain

アカウントがないのでユーザ登録画面に進みます。

f:id:akiomik:20130207204632p:plain

試しに空白のままユーザ登録してみると、たくさんエラーが表示されました。

f:id:akiomik:20130207205455p:plain

次は適当に項目を埋めて登録します。するとみごと登録完了画面が表示されました。

f:id:akiomik:20130207205616p:plain

最後に登録した情報を使ってログインしてみます。

f:id:akiomik:20130207205803p:plain

これで今度こそトップ画面の表示ができました。 ばんざーい。

さいごに

非常に長くなってしまいましたが、とりあえず一通り実装の流れを紹介してみました。 だいたいサンプルを元に作っているんですが、やりたいことを実現する方法を知るまでにちょっと困ったので、誰かの助けになれば幸いです。 時間がなくてあんまり触れてませんが、次はもうちょっとplay2.1っぽいことをしたいですね。