noggy’s blog

自分用の備忘録です。。。

8.1 セッション

この節と次の節では、セッション機能を作成する準備として、Sessionコントローラ、ログイン用のフォーム、両者に関連するコントローラのアクションを作成する。

セッションをRESTfulなリソースとしてモデリングできると、他のRESTfulリソースと統一的に理解ができるようだ。
例えば、ログインページでは
(1)newで新しいセッションを出力(HTTPメソッドのGETに対応)
(2)ページでログインするとcreateでセッションを実際に作成して保存(POSTに対応)
(3)ログアウトするとdestroyでセッションを破棄(DELETEに対応)

ここで大切なことは、UsersリソースではバックエンドでUserモデルを介してDB上の永続的データにアクセスするのに対し、Sessionリソースではcookiesを保存場所として使う点である。すなわち、Sessinonリソースではモデルを使わない、ということに注意しておく。

8.1.1 Sessionsコントローラ

ログインとログアウトの要素を、Sessionsコントローラの特定のRESTアクションにそれぞれ対応付けることにする。
具体的には、
(1)ログインのフォームは、newアクション
(2)実際のログインは、createアクションにPOSTリクエストを送信
(3)ログアウトは、destroyアクションにDELETEリクエストを送信

コントローラ

まずはSessionsコントローラとnewアクションを生成する。

$ rails g controller Sessions new

Sessionsは複数形!(よだんですが、本文中、リソースやコントローラをSessionと単数形を用いていることが散在していますがなんか理由あるのだろうか?)ここで、createやdestroyには対応するビューが必要ないので、無駄なビューを作成しないためにもnewだけにしている。

ルーティング

Usersリソースのときは専用のresourcesメソッドを使って、RESTfulなルーティングを自動的にフルセットで利用できるようにした。
がSessionリソースではフルセットはいらないので、「名前付きルーティング」だけを使う。
この名前付きルーティングでは、

  • GETリクエスト、POSTリクエストをloginルーティング
  • DELETEリクエストをlogoutルーティング

で扱う。

config/routes.rb

Rails.application.routes.draw do
  root 'static_pages#home'
  get     '/help',    to: 'static_pages#help'
  get     '/about',   to: 'static_pages#about'
  get     '/contact', to: 'static_pages#contact'
  get     '/signup',  to: 'users#new'
  get     '/login',   to: 'sessions#new' ←追加
  post    '/login',   to: 'sessions#create' ←追加
  delete  '/logout',  to: 'sessions#destroy' ←追加
  resources :users                      
end
テスト

test/controllers/sessions_controller_test.rb

require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest
  test "should get new" do              
    get login_path  ←①                                             
    assert_response :success ←②                 
  end
end

①:名前付きルートを使用。ログインフォーム画面へ。
②: レスポンスが成功したらtrue、失敗ならfalse

8.1.2 ログインフォーム(ビュー)

ここではログインフォームを整える。ログインフォームとユーザー登録フォームはほとんど違いがない。
違いは、ユーザー登録フォームに4つあったフィールドが、[Emai]lと]Password]の2つに減っていることだけ。

ログインフォームで入力した情報に誤りがあったときは、ログインページをもう一度表示してエラーメッセージを出力する。
7.3.3はエラーメッセージの表示に専用パーシャルを使ったが、そのパーシャルではActive Recordによって自動生成されるメッセージを使っていた。

今回扱うセッションはActive Recordオブジェクトではないので、Active Recordがええ感じにエラーメッセージを表示してくれない。
そこで今回は、フラッシュメッセージでエラーを表示する。

ログインフォームの作成

ログインフォームの作成の前に、ちょっと復習。
以前、ユーザーの新規登録フォームでは、次のようにform_forヘルパーを使い、ユーザーのインスタンス変数@userを引数に取っていた。

<%= form_for(@user) %>
:
<% end %>

セッションファームとユーザーの新規登録フォームの最大の違いは、セッションにはSessionモデルがなく、そのため@userのようなインスタンス変数に相当するものがない、という点。したがって、新しいセッションフォームを作成するときは、form_forヘルパーに追加の情報を独自に渡さないといけない!

そこで、セッションの場合はリソースの名前とそれに対応するURLを具体的に指定する。

form_for(:session, url: login_path)

シンボルを使うことで、params[:session][:email]、params[:session][:password]という形で送信することができる。たぶん。

views/sessions/new.html.erb

<% provide(:title, 'Log in') %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %> ←①

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>←②

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

①:モデルを通さなくても、POSTリクエストができる
②:emailがキーとなる。passwordも同じ。
ここで、[Log in]リンクがまだきかないので、ブラウザのアドレスバーに「/login」とURLを直接入力する。

    <form action="/login" accept-charset="UTF-8" method="post">
    <input name="utf8" type="hidden" value="&…" />
      <label for="session_emails">Emails</label>
      <input class="form-control" type="text" name="session[email]" id="session_email" /> ←①
      
      <label for="session_password">Password</label>
      <a href="/password_resets/new">(forget password)</a>
      <input class="form-control" type="password" name="session[password]" id="session_password" /> ←②
      <input type="submit" name="commit" value="Log in" class="btn btn-primary" data-disable-with="Log in" /> 
</form>    

このログインフォームのHTMLの①,②より、フォーム送信後に、paramsハッシュに入る値がparams[:session][:email]、
params[:session][:password]にとなることが推測できる。つまり、ハッシュの入れ子構造。

8.1.3 ユーザーの検索と認証

ログインでセッションを作成する場合の順は次のとおり
(1)入力が無効な場合の処理
(2)ログインが失敗した場合のエラーメッセージの表示
(3)ログインに成功した場合の土台部分の作成

アクションの作成(create、new、destroy)

上記(1)のために、まずSessionsコントローラで、createアクション、newアクション、destroyアクションを作成しておく。
controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def new
  end

  def create
    render 'new'                                                                
  end

  def destroy
  end
end
ログインフォームからの送信とデバッグ情報

ログインフォーム(sessions/new)からの送信後、デバッグ情報を確認すると

session: 
    email: 'user@example.com'←①
    password: 'foobar'←②
  commit: Log in
  action: create
  controller: sessions

paramsハッシュでは、上のようにsessionキーの下にメールアドレス①とパスワード②がある。
特にparamsは次のような入れ子ハッシュになっている。

{ session: { password: "foobar", email: "user@example.com"} }

sessionがキーで、{ password: "foobar", email: "user@example.com"}が値。この結果、

params[:session][:email]
params[:session][:password]

で、フォームから送信されてきたメールアドレスとパスワードを取得できる。
要するにcreateアクションの中では、ユーザー認証に必要なあらゆる情報をparamsハッシュから簡単に取り出せるということ。

createアクションの実装

createアクションの実装のため、Active Recordが提供する「User.find_byメソッド」とhas_secure_passwordが提供する「authenticateメソッド」を使う。
ここで、authenticateメソッドは、認証に失敗したときにfalseを返すことに注意。なのでif文の条件式に使える。以上を踏まえると、

controllers/sessions_controller.rb

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)  ←①       
    if user && user.authenticate(params[:session][:password]) ←②
      # ユーザーログイン後にユーザー情報のページにリダイレクト                  
    else
      # エラーメッセージを作成
      render 'new'                                               
    end

①:email情報でユーザーをひっぱってきたい。email情報なのでfind_by
②:Rubyではnilとfalse以外のすべてのオブジェクトは、真偽値ではtrueになる。

User Password a&&b
存在しない なんでもよい (nil && 「オブジェクト」)==false
有効なユーザー 誤ったパスワード (true && false)==false
有効なユーザー 正しいパスワード (true && true)==true

この表より、入力されたメールアドレスを持つユーザーがDBに存在し、かつ入力されたパスワードがそのユーザーのパスワードである場合のみ、ifi文がtrueになることが分かる。

8.14 フラッシュメッセージの表示

ログインに失敗したときのエラーメッセージを表示させる。ここで、ユーザー登録の場合、エラーメッセージはActive Recordオブジェクトに関連付けられていたので、Userモデルのエラーメッセージを使えた。しかし、今のセッションではActive Recordのモデルを使っていないため、その手が使えない。そこで、ログインに失敗したときは代わりにフラッシュメッセージを表示させる。

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'                                                              
    end
  end

①:ユーザーにフィードバックを返すことが大切。本当は正しくない。

flashメッセージが消えない問題

上のコードの①は正しくない。なぜなら、リクエストのフラッシュメッセージが一度表示されると消えずに残ってしまうから。
表示したテンプレートをrenderで強制的に再レンダリングしても、リクエストとして見なされないため、リクエストのメッセージが消えない、というのが理由。

8.1.5 フラッシュのテスト

フラッシュメッセージが消えない問題は、アプリの小さなバグ。よって、「エラーをキャッチするテストを先に書いて、そのエラーが解決するようにコードを書く」とよい。そこで、ログインフォームの送信についての簡単な統合テストを作成することから始める。

$ rails g integration_test users_login
      invoke  test_unit
      create    test/integration/users_login_test.rb

テストコードの手順は次のとおり
1.ログイン用のパスを開く
2.新しいセッションのフォームが正しく表示されたことを確認する
3.わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
4.新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認する
5.別のページにいったん移動する
6.移動先のページでフラッシュメッセージが表示されていないことを確認する

test/integration/users_login_test.rb

require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  
  test "login with invalid information" do 
    get login_path ←①      
    assert_template 'sessions/new' ←② 
    post login_path, params: { session: { email: "", password: "" } } ←③ 
    assert_template 'sessions/new' ←④ 
    assert_not flash.empty? ←⑤     
    get root_path ←⑥                             
    assert flash.empty?                                     
  end
end

①:Sessionsコントローラのnewアクションを取得
②:ログイン画面が描画されていればtrue
③:無効なparamsハッシュを使って、createアクションへPOST
④:ログイン画面が描画されていればtrue
⑤:flashメッセージが空ならfalse
⑥ :Homeページを取得
⑦:flashが空であればtrue

flash.now

失敗するテストをパスさせるには、flashflash.nowに置き換える。
flashのメッセージと異なり、flash.nowのメッセージはその後リクエスト発生したときに消滅する。

  def create
    user = User.find_by(email: params[:session][:email].downcase)               
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクト                             
    else
      flash.now[:danger] = "Invalid email/password combination"  ←変更               
      render 'new'
    end                                                              
  end