Play Framework 2.3 For Java ことはじめ #10 ログイン認証編
Play 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でもページこそ存在してないけど、こんな記述がありました。
@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
にアクセスしてみると、、
って感じで、フォームが出てきて、名前を入力しないとエラーになって、/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.java
にcreate
メソッド作る。
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)
メソッドを自前で作る。
ユーザー名とパスワードが存在すれば、authenticate
はtrue
を返すので、validate()
メソッドはnull
を返す。これは、validationした結果問題はありませんよみたいなことです。エラーがある場合は、エラーメッセージを返しています。
authenticate(String name, String password)
の方は、playにとって意味のあるメソッドじゃなくて適当に作ったやつ。教えてて思ったのは、このあたりのフレームワークにとって特別な存在なのか、どうでもいいものなのかっていう判断が初めての人には難しいのかも。
これで以下のところまでいけるはず。
- loginページにアクセス
- テストユーザーを作成
- admin、password123以外だとログインできなくて
/login
に戻ってくる - テストユーザーの情報を入れればログインして
/
にリダイレクト
ただ、これだと、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 » jbcrypt » 0.3mを使おうってことらしい。stackoverflow見てても、jbcryptを使ってるっぽいからこれに決めた。
build.sbt
にjbcryptの依存関係を追加します。
libraryDependencies ++= Seq( javaJdbc, javaEbean, cache, javaWs, "org.mindrot" % "jbcrypt" % "0.3m" )
User.java
のauthenticate
メソッドと、ユーザー作成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アプリケーションについてです。