Ruby on Railsチュートリアル第8章
第8章(基本的なログイン機構)
セッション
- HTTPはRESTの原則であるステートレス性(前のやり取りの情報に依存せず独立)を持つ。
- セッションと呼ばれる半永続的な接続をコンピュータ間に設ける。そのセッションの情報を保持するのがcookeis。
- セッションの一連の流れは、RESTアクションと紐づける。ログインフォーム→
new
、ログイン→create
、ログアウト→destroy
。 - セッションの情報はusersがparams[:user]でネストされたハッシュによって属性を取り出した場合と同様に、params[:session]で中身を取り出せる。
セッションとユーザー登録フォームの違い
- ログインセッションのフォームはユーザー登録フォームのビューがEmailとPasswordになっただけ。
- ユーザー登録フォームのエラーメッセージは、ActiveRecordのエラーメッセージを表示したが、今回はflashを使用する必要がある。(∵ActiveRecordオブジェクトでは無いため)
- セッションにはsessionモデルが無く、@session変数も当然無いため、
form_with(model: @user)
のような記述が出来ず、form_with(url: login_path, scope: :session)
でscopeでモデルを指定。
ログインセッション
ログイン(create)を実装。find_by
でネストされたハッシュからemailでユーザー検索し、if文でuserが存在していれば論理積で認証。
[app/controllers/sessions_controller.rb] class SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) # ユーザーログイン後にユーザー情報のページにリダイレクトする else # エラーメッセージを作成する render 'new', status: :unprocessable_entity end end def destroy end end
エラーのフラッシュメッセージ
ユーザー登録のエラーは以下のように_error_messageパーシャルでActiveRecordのメッセージをそのまま利用していた。
<% @user.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %>
これが出来ないので、ユーザー登録の成功(:seuccess)と同様にフラッシュメッセージを用意。
[app/controllers/sessions_controller.rb] def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) # ユーザーログイン後にユーザー情報のページにリダイレクトする else flash[:danger] = 'Invalid email/password combination' render 'new', status: :unprocessable_entity end end
但し、通常のflashはリダイレクトの際に使用されるため、flash.now
を使用する。
ログイン失敗のテスト
統合テストでログイン失敗時のテストをフラッシュメッセージの動作と併せて実施。
[test/integration/users_login_test.rb] class UsersLoginTest < ActionDispatch::IntegrationTest test 'login with invalid information' do get login_path #ログインパスを取得。 assert_template 'sessions/new' #sessionのnewページが表示されているか。 post login_path, params: {session: { email: ' ', password: 'invalid' }} #ログインパスにpostで無効な情報を送信。 assert_template 'sessions/new' #sessionのnewページがレンダリングされているか? assert_response :unprocessable_entity #HTTPレスポンスが返ってきているか。 assert_not flash.empty? #フラッシュメッセージが表示されているか? get root_path #適当な別の処理を実行。 assert flash.empty? #フラッシュメッセージが消えているか? end end
ログイン
sessionメソッドとcokkiesメソッド
sessionメソッドを扱うので9章のcokkiesメソッドも先回りして簡単にまとめておきます。
sessionメソッド
一連のセッション用に必要なデータを残すためのメソッドでハッシュとして扱える。
一時cookiesに暗号化済みのデータを保管する。
ブラウザを閉じると、一時cookiewsの有効期限は終了する。
cookiesメソッド
ブラウザ終了時にも残しておきたいデータを保存しておくためのメソッドでハッシュとして扱える。
sessionメソッドと違ってオプションのexpires(有効期限)の指定で、永続的なセッションを作ることが可能。
reset_sessionメソッド
セッションメソッドを使用したlog in
メソッドを使用し、ログイン動作を実装する場合、必ずreset_session
メソッドを組み込む。
reset_session
メソッドは、セッションをリセットすることでセッション固定攻撃を防ぐRailsの組み込みメソッド。
current_userメソッド
current_userメソッドの部分で重要な情報が書かれていました。
まず完成形のcurrent_userメソッドは以下のようになります。
# 現在ログイン中のユーザーを返す(いる場合) def current_user if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) end end
idでの検索のため、find_by
では無く、find
でよいような気がしますが、ここではIDが無効な場合であっても例外を発生するのではなく、nilを返してほしい部分。
これは、find
の場合指定されたidに対応するユーザーを毎回探そうとして(データベースへリクエスト)例外を発生するが、find_by
は条件に合致する最初のデータを返しそれをキャッシュとして保持しておくため、current_userが何回呼び出されたとしても一回の呼出しで済む。
このようなメソッド呼び出しの結果を変数に保存(インスタンス化)し、それを再利用する手法はメモ化といい高速化させるテクニック。
更に、「| | =」という書き方は自己代入で、左辺がnilまたはfalseであれば右辺を代入する。
digestメソッド
ここのdigestメソッドも複雑で三項演算子については9章で詳細が出ますのでそのタイミングでまとめます。
ここでは、テストユーザー用のfictureにpassword_digest
属性を追加したいが、Bcryptパスワードのハッシュ化のコスト(セキュリティの強度)は最低値でよいため、最低コストでパスワードをハッシュ化したものをdigestメソッドで定義している。このdigestメソッドはクラスメソッド(クラス自身の持つメソッド)で定義。
テストからfixtureデータを参照
次のようにテストで記述した場合、@userはusers.yml
を参照し、:micaelというシンボルをkeyとしてユーザーを参照する。
def setup @user = users(:michael) end
ログイン成功のテスト
統合テストでログイン成功時のテストを実施。
ここで前章のfollow_redirect!
の動作を忘れておりました。最初はfollow_redirect!
の動作を記述しておらず、templateが何もないといったエラーとなったのですが、実際にページに移動するfollow_redirect!
の動作が必要みたいです。
test "login with valid information" do post login_path, params: { session: { email: @user.email, password: 'password' } } assert_redirected_to @user follow_redirect! assert_template 'users/show' assert_select "a[href=?]", login_path, count: 0 assert_select "a[href=?]", logout_path assert_select "a[href=?]", user_path(@user) end
ログアウト
ログアウト処理は、通常reset_session
をおこなえばok。但し、セキュリティ上@current_user = nil
まで入れておく方がbetter。
[app/helpers/sessions_helper.rb] # 現在のユーザーをログアウトする def log_out reset_session @current_user = nil # 安全のため end
destroyアクションのリダイレクト時には、Turboに対応するようにHTTPステータスコードを追加(ここだとsee other)しておく。
第8章ー演習ー
8.1.1
①GET login_pathとPOST login_pathとの違いを説明できますか? 少し考えてみましょう。
(解答) GET login_pathはログインフォームの表示、POST login_pathはログインフォームからデータを送信(ログイン処理)で違う。
②ターミナルのパイプ機能を使ってrails routesの実行結果とgrepコマンドをつなぐことで、Usersリソースに関するルーティングだけを表示できます。同様にして、Sessionsリソースに関する結果だけを表示させてみましょう。現在、いくつのSessionsリソースがあるでしょうか? (ヒント: パイプやgrepの使い方が分からない場合は 『コマンドライン編』の 「grepで検索する」を参考にしてみてください。)
(解答)sessionsリソースは3つのみ。
usersリソースのルーティング
$ rails routes | grep users signup GET /signup(.:format) users#new users GET /users(.:format) users#index POST /users(.:format) users#create new_user GET /users/new(.:format) users#new edit_user GET /users/:id/edit(.:format) users#edit user GET /users/:id(.:format) users#show PATCH /users/:id(.:format) users#update PUT /users/:id(.:format) users#update DELETE /users/:id(.:format) users#destroy
sessionsリソースのルーティング
$ rails routes | grep sessions login GET /login(.:format) sessions#new POST /login(.:format) sessions#create logout DELETE /logout(.:format) sessions#destroy
8.1.2
①リスト 8.4で定義したフォームで送信すると、Sessionsコントローラのcreateアクションに到達します。Railsはこれをどうやって実現しているでしょうか? 考えてみてください。(ヒント:表 8.1とリスト 8.5の1行目に注目してください。)
(解答) form_withメソッドはHTTPリクエストのPOSTを指定する。今回はセッションにおけるlogin_pathを指定しているため、sessionsのcreateアクション(POST)に到達する。
8.1.3
①Railsコンソールを使って、表 8.2のそれぞれの式が合っているか確かめてみましょう. まずはuser = nilの場合を、次にuser = User.firstとした場合を確かめてみてください。(ヒント: 必ず論理値オブジェクトとなるように、4.2.2で紹介した!!のテクニックを使ってみましょう。例: !!(user && user.authenticate('foobar')))
(解答)
irb(main):001:0> user = User.first User Load (1.5ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User:0x00007fa595312eb8 ... irb(main):002:0> !!(user && user.authenticate('foobar')) => true irb(main):003:0> user = nil => nil irb(main):004:0> !!(user && user.authenticate('foobar')) => false
8.1.5
①8.1.4の処理の流れが正しく動いているかどうか、ブラウザで確認してみてください。特に、flashがうまく機能しているかどうか、フラッシュメッセージの表示後に違うページに移動することを忘れないでください。
(解答)期待通りに動作した。
8.2.1
①有効なユーザーで実際にログインし、ブラウザでcookiesの情報を調べてみてください。このとき、sessionの値はどうなっているでしょうか? ブラウザでcookiesを調べる方法が分からなければ、今こそググってみるときです!(コラム 1.2)
(解答)暗号化されためちゃくちゃ長い文字列になっていた。
②上の演習課題と同様に、Expires(有効期限)の値について調べてみてください。
(解答)Max-ageがSessionとなっていたため、セッション終了時まで。
8.2.2
①Railsコンソールを使って、User.find_by(id: ...)で対応するユーザーが検索に引っかからなかったとき、nilを返すことを確認してみましょう。
(解答)
irb(main):003:0> User.find_by(id: 5) User Load (24.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]] => nil
②先ほどと同様に、今度は:user_idキーを持つsessionハッシュを作成してみましょう。リスト 8.17に記したステップに従って、||=演算子がうまく動くことも確認してみましょう。
(解答)
irb(main):004:0> session = {} => {} irb(main):005:0> session[:user_id] = nil => nil irb(main):006:0> @current_user ||= User.find_by(id: session[:user_id]) User Load (2.6ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]] => nil irb(main):007:0> session[:user_id] = User.first.id User Load (1.5ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => 1 irb(main):008:0> @current_user ||= User.find_by(id: session[:user_id]) User Load (0.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<User:0x00007f768e09e708 ... irb(main):009:0> @current_user ||= User.find_by(id: session[:user_id]) => #<User:0x00007f768e09e708 ... irb(main):010:0> @current_user => #<User:0x00007f768e09e708 id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: Sat, 15 Apr 2023 12:17:28.353917000 UTC +00:00, updated_at: Sat, 15 Apr 2023 12:17:28.353917000 UTC +00:00, password_digest: "[FILTERED]">
8.2.5
確認とJacaScript側のリファクタリングなので飛ばします。
8.2.6
①リスト 8.15の8行目にあるif userから後ろをすべてコメントアウトすると、ユーザー名とパスワードを入力して認証しなくてもテストがパスしてしまうことを確認してください(リスト 8.37)。パスしてしまう理由は、リスト 8.9では「メールアドレスは正しいがパスワードが誤っている」ケースをテストしていないからです。このテストがないのは重大な手抜かりですので、テストスイートで正しいメールアドレスをUsersのログインテストに追加して、この手抜かりを修正してください(リスト 8.38)。テストが red (失敗)することを確認し、それから先ほどの8行目以降のコメントアウトを元に戻すと green (パス)することを確認してください。この演習の修正は重要なので、この後の 8.3のメインのコードにも修正を反映してあります。
(解答) このテストを追加することで、コメントアウトしたらちゃんとレッドになった。
[test/integration/users_login_test.rb] test "login with valid email/invalid password" do get login_path assert_template 'sessions/new' post login_path, params: { session: { email: @user.email, password: "invalid" } } assert_response :unprocessable_entity assert_template 'sessions/new' assert_not flash.empty? get root_path assert flash.empty? end
②safe navigation演算子(または“ぼっち演算子”)と呼ばれる&.を用いて、リスト 8.15の8行目の論理値(boolean値)の条件式を、リスト 8.3919 のようにシンプルに変えてください。Rubyのぼっち演算子を使うと、obj && obj.methodのようなパターンをobj&.methodのように凝縮した形で書けます。変更後も、リスト 8.38のテストがパスすることを確認してください。
(解答) 変更後もパスする。
8.2.7
①リスト 8.40のlog_inの行をコメントアウトすると、テストスイートは red になるでしょうか? それとも green になるでしょうか? 確認してみましょう。
(解答) REDになる
FAIL UsersSignupTest#test_valid_signup_information (2.17s) Expected false to be truthy. test/integration/users_signup_test.rb:32:in `block in <class:UsersSignupTest>'
②現在使っているテキストエディタの機能を使って、リスト 8.40をまとめてコメントアウトできないか調べてみましょう。また、コメントアウトの前後でテストスイートを実行し、コメントアウトすると red に、コメントアウトを元に戻すと green になることを確認してみましょう。(ヒント: コメントアウト後にファイルを保存することを忘れないようにしましょう。また、テキストエディタのコメントアウト機能については『テキストエディタ編』の 「コメントアウト機能」などを参照してみてください。)
(解答) 出来る。
8.3
①ブラウザで「Log out」リンクをクリックしたときに、Webサイトのレイアウトが正しく切り替わることを確認してください。このログアウト時のレイアウト切り替えと、リスト 8.46のテストの末尾3行にどのような対応関係があるかを考えて答えてください。
(解答) 正しく切り替わる。ログイン状態で表示されていた「logout」や「user」リンクが表示されなくなり、「signup」リンクがあることを確認。
②リスト 8.46にある2つのテストの内容が少し増えてしまいました。このままだとテストのメンテナンスがやりにくくなる可能性があります。テストを分割する戦略はいくつか考えられますが、その1つは、関連するテストごとにRubyのクラス(4.4)を別途作る方法です。クラスを作ると、テストに必要なsetupメソッドの中で関連する部分を継承(4.4.2)で再利用できます。 ここで重要なのはリスト 8.48でハイライトされているsuperです。superは、現在のクラスのスーパークラス(クラス階層の1つ上にあるクラス)にある、対応するsetupメソッドを呼び出します。
この演習問題のトピックはやや高度なので、基本的にリスト 8.48のテストコードが green になることを確認できればOKです。しかし、このテストコードを理解してみたい方はぜひじっくり読んでみてください。リファクタリング前のリスト 8.46と注意深く見比べてみると、以前よりもずっと多くのことを発見できるようになった自分に驚くことでしょう。テストのリファクタリングについては、この後の 11.3.3、 12.3.3、 13.3.5で詳しく扱います。
(解答) GREENになる。
第8章ーまとめー
- ブラウザ上に一時的に情報を保持する方法としてcookiesがある。メソッドとしては
session
メソッドとcookies
メソッドでcookies
メソッドは永続化も可能。 - 通常
flash
はリダイレクト時に使用されるため、それ以外のrenderメソッドなどの場合はflash.now
とする。 - Railsの組み込みメソッドの
reset_session
メソッドはセッション固定攻撃の対策として必ず入れておく。 find
メソッドはidに合致するデータを毎回検索するが、find_by
は合致する最初のデータを検索しキャッシュに保持する。そのため以降検索無しに使いまわせる。- テスト時にリダイレクト先のテストまで記述する場合は
follow redirect!
で実際に移動する必要がある。
第8章終わり🐻