kuma0319のブログ

webエンジニアになるためのアウトプットブログ

Ruby on Railsチュートリアル第8章

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章終わり🐻