Play Framework 2.3 For Java ことはじめ #10 ログイン認証編

f:id:masato47744:20140720125846p:plainPlay Framework 2.3 For Java 入門記事一覧

第10回はログイン認証です。playの公式ドキュメントは色々な要素が紹介されてて、2.2系のdocumentには、認証に関するトピック - Adding Authenticateionがあったんだけど、2.3系にはログイン認証に関するページが存在しなかったので、色々見ながら最低限それっぽく動くところまでをやってみました。

最終的なソースはこちらmpon/play-shop · GitHub

2.2系のdocumentには、@Security.Authenticatedでリクエストの認証をやってるっぽいけど、2.3ではどうなんでしょう。deprecatedなやり方なのかな?とか不安に思ったんですが、2.3系のdocumentでもページこそ存在してないけど、こんな記述がありました。

JavaActionsComposition

@Security.Authenticated
@Cached(key = "index.result")
public static Result authenticatedCachedIndex() {
    return ok("It works!");
}

Note: play.mvc.Security.Authenticated and play.cache.Cached annotations and the corresponding predefined Actions are shipped with Play. See the relevant API documentation for more information.

という記述があったから、deprecatedではないだろうと判断しました。なので、2.2系のチュートリアル見ながら2.3系でも同じことができるか検証していきます。

1. ひな形の作成

毎度のことですが、コマンドラインならひな形を作ります。ログイン認証が必要なECサイトを作るイメージで名前はplay-shopとでもしておきますか。

$ activator new play-shop play-java
$ cd play-shop
$ activator
[play-shop] run

で、localhost:9000でいつもの例のやつが見れます。

2. ログインに使うためのモデルを作る

何はともあれ、モデルがなきゃ始まりません。こんな感じで、app/models/User.javaを作ります。今回はめんどいので、JPAじゃなくEBeanを使います。ユーザー名が入力されてないとエラーになるようなアノテーションを入れています。

package models;

import java.util.*;
import javax.persistence.*;

import play.db.ebean.*;
import play.data.format.*;
import play.data.validation.*;

@Entity 
public class User extends Model {

  @Id
  @Constraints.Min(10)
  public Long id;
  
  @Constraints.Required
  public String name;
  
  public String password;
    
  public static Finder<Long, User> find = new Finder<Long,User>(
    Long.class, User.class
  );
}

あとはDBが起動するように、conf/application.confをいじって、以下のところコメントを外しておきます。

db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:mem:play"
db.default.user=sa
db.default.password=""

ebean.default="models.*"

3. コントローラーに処理を書いていく

conf/routesはこんな感じです。2.2のチュートリアルとは違うかもしれませんが、その辺は適当です。 /loginにアクセスして、ユーザー名とパスワード入力して、送信先は、POST/loginで、認証できたら、/へリダイレクトするって感じです。

# Home page
GET     /                           controllers.Application.index()
GET     /login                      controllers.Application.login()
POST    /login                      controllers.Application.authenticate()

そして、コントローラーに処理を書いていきます。app/controllers/Application.javaを以下のように編集していきます。

package controllers;

import play.*;
import play.mvc.*;
import play.data.*;

import views.html.*;

import models.*;

public class Application extends Controller {

    public static Result index() {
        return ok(index.render());
    }

    public static Result login() {
        return ok(login.render(Form.form(User.class)));
    }

    public static Result authenticate() {
        Form<User> loginForm = Form.form(User.class).bindFromRequest();
        if (loginForm.hasErrors()) {
            return badRequest(login.render(loginForm));
        }
        session().clear();
        session("name", loginForm.get().name);
        return redirect(routes.Application.index());
    }

}

loginメソッドを作って、return ok(login.render(Form.form(User.class)));で、Formを渡しつつ、login.scala.htmlをレンダリングするようにします。 POSTされたときのauthenticateメソッドを作って、Formのバリデーションに引っかかったらログインページに戻す。正常ならセッションにユーザー名をセットしてトップにリダイレクトっていう流れです。

あとは、loginページを作る必要があります。app/views/login.scala.htmlを作って、見た目になんの工夫もなくただ単にユーザー名とパスワードのフォームを配置した。@inputPasswordなんてヘルパーも用意されてるのね。

@(loginForm: Form[User])

@import helper._

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Login</title>
</head>
<body>
  @form(routes.Application.authenticate) {
    <h1>Sign in</h1>
    @inputText(loginForm("name"))
    @inputPassword(loginForm("password"))
    <p>
      <button type="submit">Login</button>
    </p>
  }
</body>
</html>

最後にもともとあったサンプルページをちょっと修正。主題からそれるので、main.scala.htmlは削除して、index.scala.htmlはただ単にhello world表示するだけのページに以下のように書き換えた。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>TOP</title>
</head>
<body>
    <p>hello world</p>
</body>
</html>

これで、/loginにアクセスしてみると、、

f:id:masato47744:20140806010708p:plain

って感じで、フォームが出てきて、名前を入力しないとエラーになって、/loginページに戻される。名前になんでもいいから入れると、ログインできたことになって、/に飛んでhello worldが表示されるってアプリケーションができました。

ふむふむ、ひとまずそれっぽい感じになった。ただ、全然認証になっていないので、作っていきます。

4. 認証をDBの値から取得して突き合わせるようにする

まず、テストユーザー作るために、普通はありえないけど、login.scala.htmlページにユーザー作ってくれるリンクを作成する。ユーザー名はテキトーで、admin、パスワードはpassword123ってことで。

  <p>
    <a href="@routes.Application.addUser">create test user</a>
    <dl>
      <dt>name</dt>
      <dd>admin</dd>
      <dt>password</dt>
      <dd>password123</dd>
    </dl>
  </p>

routesには仮ユーザー作成のためのリンクを加えて

GET     /addUser                    controllers.Application.addUser()

コントローラーに処理を書く。adminって名前のユーザーを検索してなければ、ユーザーを作成します。

    public static Result addUser() {
        User user = User.find.where().eq("name", "admin").findUnique();
        if (user == null) {
            User.create("admin", "password123");
        }
        return redirect(routes.Application.login());
    }

実際の登録処理は、モデルに書くので、User.javacreateメソッド作る。

  public static void create(String name, String password) {
    User user = new User();
    user.name = name;
    user.password = password;
    user.save();
  }

これで、テストユーザーが作られるようになりました。前準備完了。

認証処理は、モデルのUser.javaに以下のようにvalidate()メソッドを書く。これは、playでは特別なメソッドで、これを書いておくとフォームのバリデーションを適用してくれる。formのドキュメントにも書いてありますね。

  public String validate() {
    if (authenticate(name, password)) {
      return null;
    }
    return "Invalid user and password";
  }

  private Boolean authenticate(String name, String password) {
    User user = find.where().eq("name", name).eq("password", password).findUnique();
    return (user != null);
  }

んで、そのvalidate()で、ユーザー名とパスワード名でSQLでレコード取得しにいって、 レコードが存在すればtrueを返すBoolean authenticate(String name, String password)メソッドを自前で作る。 ユーザー名とパスワードが存在すれば、authenticatetrueを返すので、validate()メソッドnullを返す。これは、validationした結果問題はありませんよみたいなことです。エラーがある場合は、エラーメッセージを返しています。

authenticate(String name, String password)の方は、playにとって意味のあるメソッドじゃなくて適当に作ったやつ。教えてて思ったのは、このあたりのフレームワークにとって特別な存在なのか、どうでもいいものなのかっていう判断が初めての人には難しいのかも。

これで以下のところまでいけるはず。

  1. loginページにアクセス
  2. テストユーザーを作成
  3. admin、password123以外だとログインできなくて/loginに戻ってくる
  4. テストユーザーの情報を入れればログインして/にリダイレクト

ただ、これだと、logoutできないのと、login失敗したときのエラーメッセージがない。 なのでroutesに/logoutを追加して、

GET     /logout                     controllers.Application.logout()

index.scala.htmlにlogoutのリンク作る。

<body>
    <ul>
        <li><a href="@routes.Application.logout">logout</a></li>
    </ul>
</body>

コントローラーにはlogoutメソッド作る。session().clear()ってやれば、logoutできます。 これは、ログイン処理のときにやった、session("name", loginForm.get().name);をクリアしてるってこと。

    public static Result logout() {
        session().clear();
        return redirect(routes.Application.login());
    }

んで、エラーメッセージを表示するために、login.scala.htmlの好きなとこにこれを追加する。

    <h1>Sign in</h1>
    @if(loginForm.hasGlobalErrors) {
    <p class="error">
        @loginForm.globalError.message
    </p>
    }

hasGlobalErrorsというのはFormクラスのメソッドで、モデルで追加したvalidate()メソッドで返したエラーが存在するかが分かるので、これでエラーメッセージが出る。

これで、データベースの値で認証できつつ、エラーメッセージ、ログアウトが実装できました。

5. 認証が必要なページを設定する

ログインっぽいことはできたけど、ログインしなくても/にはアクセスできてしまっていたので、 これを認証必要なページにします。認証必要というのは、認証しないと見れないということです。

これは冒頭で触れたplayが用意しているSecurity.Authenticatorを継承すればよいだけで、 まずは、コントローラーに、app/controllers/Secured.javaを作って、

package controllers;

import play.*;
import play.mvc.*;
import play.mvc.Http.*;

import models.*;

public class Secured extends Security.Authenticator {

    @Override
    public String getUsername(Context ctx) {
        return ctx.session().get("name");
    }

    @Override
    public Result onUnauthorized(Context ctx) {
        return redirect(routes.Application.login());
    }
}

getUsername(Context ctx)で、ログイン成功したときの処理。ここは、セッションに入れた値を返すようにする。onUnauthorized(Context ctx)は認証していないときの処理でloginページにリダイレクトするって処理。

ここまでいったら、あとは、こんな風にコントローラーのところに、@Security.Authenticated(Secured.class)アノテーション書くだけで、好きなアクションに認証がつけられる。

    @Security.Authenticated(Secured.class)
    public static Result index() {
        return ok(index.render());
    }

こうすると、logoutしたあとに、/に直接アクセスしても認証してないということで、/loginページに行くことが確認できると思います。

6. パスワードの暗号化

かなり認証っぽくなりましたが、最後に、このままでは、パスワードが平文でDBに格納されてしまいます。 DBの管理者や開発者、クラックされたときにだだ漏れです。そんなことはWebアプリケーションではありえないので、これを暗号化します。 んで、どうやるかですが、play password hashとかでググると、

Better password hashing in Play 2.2 (Java)

とかが出てきて、要は普通のMD5, SHA1 and SHA256は処理速度は速いんだけど、その分ブルートフォースアタックとかに弱いからMaven Repository: org.mindrot &#187; jbcrypt &#187; 0.3mを使おうってことらしい。stackoverflow見てても、jbcryptを使ってるっぽいからこれに決めた。

build.sbtにjbcryptの依存関係を追加します。

libraryDependencies ++= Seq(
  javaJdbc,
  javaEbean,
  cache,
  javaWs,
  "org.mindrot" % "jbcrypt" % "0.3m"
)

User.javaauthenticateメソッドと、ユーザー作成createメソッドのところをjbcryptを使うように修正。 authenticateメソッドは、今まで、passwordをwhere句で文字列として比較してたけど、BCryptのcheckpwメソッドを使うようだ。 あとは、ユーザーを作成する方で、createメソッドで、パスワードを暗号化して保存するように変えます。

  private Boolean authenticate(String name, String password) {
    User user = find.where().eq("name", name).findUnique();
    return (user != null && BCrypt.checkpw(password, user.password));
  }

  public static void create(String name, String password) {
    User user = new User();
    user.name = name;
    user.password = BCrypt.hashpw(password, BCrypt.gensalt());
    user.save();
  }

sbtを再読み込みしたいのと、DBを一旦クリアしたいので、activatorをexitして、再度立ち上げてrun。

そして、/loginページで、テストユーザーを作成してからログインしてみれば、ログインできる。 そして、一旦ログアウトしたあと、パスワードを間違えればちゃんとログインできてないことも確認できる。

今回はメモリ上のDBを使っているので、登録されているレコードを見るのに、h2-browserというのを使います。コンソールで、runをしてるアプリケーションを一旦停止して、そのまま以下のコマンドを打つ。

[play-shop] h2-browser

とやると、ブラウザでDBビューアーみたいのが開くから、JDBC URLをjdbc:h2:mem:playに変えて、接続して、select * from userしてみれば、パスワードのところに暗号化された文字列が入っていることが確認できます。

まとめ

railsとかだと、gemを見つけてきてそれでやるってのが普通だからどれが一番ナウいのか?を見極めるのが難しいんだけど、playにはそれっぽいクラスが最初から用意されてて、初心者には嬉しい感じ。まぁでも、もっとOAuthとか別の認証もとりいれようと思ったらライブラリ入れた方がよいんだろうけど。

思ったより簡単でした。というか、自分がだんだんplayに慣れてきたからってのもあるけど。

第11回は、リレーションを持つデータのCRUDアプリケーションについてです。