0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

railsチュートリアルまとめ8 基本的なログイン機構

Last updated at Posted at 2023-04-30

個人的リマインド用

参考
Ruby on Rails チュートリアル プロダクト開発の0→1を学ぼう

基本的なログイン機構

ログインの基本的な仕組みとは、ブラウザがログインしている状態を保持し、ユーザーによってブラウザが閉じられたら状態を破棄する認証システムのこと。
また、ログインしている人だけがアクセスできるページや使える機能などがあり、このような制限や制御の仕組みを認可モデルという

セッション

HTTPはステートレスなプロトコル。文字通り状態を持たない。この本質的な特性のため、ブラウザのあるページから別のページに移動した時に、ユーザーのIDを保持しておく手段がHTTPプロトコルの中には全くない。そこで、ユーザーログインの必要なWebアプリケーションでは、セッション(session)と呼ばれる半永続的な接続をコンピュータ間(ユーザーのパソコンのWebブラウザとRailsサーバーなど)に別途設定する。

Railsでセッションを実装する方法として最も一般的なのはcookiesを使う方法。cookiesとは、ユーザーのブラウザに保持される小さなテキストデータ。cookiesは別のページに移動しても消えないので、ユーザーIDなどの情報を保存できる。

Sessionsコントローラ

まず、ログインとログアウトの要素を、Sessionsコントローラの特定のREStアクションにそれぞれ対応づける。
ログインフォームはnewアクションで処理し、createアクションにPOSTリクエストを送信すると、ログインする。また、destroyアクションにDELETEリクエストを送信すると、ログアウトする。

rails g controller Sessions new

createやdestroyには対応するビューがないので、newだけを指定して作成する。

Usersリソースではresourcesメソッドを使って、RESTfulなルーティングを自動的にフルセットで利用できるようにしたが、今回はフルセットはいらないので「名前付きルーティング」だけを行う。使うのはログイン時のGETとPOST、ログアウトでDELETE。

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
HTTPリクエストメソッド URL 名前付きルーティング アクション名 用途
GET /login login_path new 新しいセッションのページ(ログイン)
POST /login login_path create 新しいセッションの作成(ログイン)
DELETE /logout logout_path destroy セッションの削除(ログアウト)

ログインフォーム

フォームは[Email]と[Password]だけ。前回はエラーメッセージの表示に専用のパーシャルを使ったが、そのパーシャルではActive Recordによって自動生成されるメッセージを使っていた。しかし、今回扱うセッションはActive Recordオブジェクトではないので、以前の方法は期待できない。そこでフラッシュ、メッセージでエラーを表示する。

formへの渡し方
前回はform_with(model: @user)と書くだけで、「フォームのactionは/usersというURLのPOSTである」と自動的に判定していたが、セッションにはセッションモデルがないので、別の方法で行う必要がある。具体的には、リソースのスコープ(ここではセッション)とそれに対応するURLを指定する必用がある。

app/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_with(url: login_path, scope: :session) 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>

生成後のHTMLフォームは

<form action="/login" accept-charset="UTF-8" method="post">
    <input type="hidden" name="authenticity_token"
           value="4d0...FFw" autocomplete="off"/>
    <label for="session_email">Email</label>
    <input class="form-control" type="email" name="session[email]"
           id="session_email"/>
    <label for="session_password">Password</label>
    <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>

paramsハッシュに入る値が、それぞれparams[:session][:email]とparams[:session][:password]になることが推測できる。

ユーザーの検索と認証

ログインでセッションを作成する場合に最初に行うのは、入力が無効な場合の処理。

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new', status: :unprocessable_entity
  end

  def destroy
  end
end

この状態で/sessions/newフォームから送信した際のデバッグ情報は

#<ActionController::Parameters
{"authenticity_token"=>"…",
  "session" =>#<ActionController::Parameters
              {"email"=>"user@example.com",
               "password"=>"foobar"} permitted: false>,
               "commit"=>"Log in",
               "controller"=>"sessions",
               "action"=>"create"} permitted: false>

ネストしたハッシュになっているので、emailにアクセスしたい時は

params[:session][:email]

passwordにアクセスしたい時は

params[:session][:password]

要するにcreateアクションの中では、ユーザーの認証に必用なあらゆる情報をparamsハッシュから簡単に取り出せる。今まで学んだ、find.byとhas_secure_password、authenticateメソッドを使って、ユーザーのログイン部分を実装したものがこちら

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

最初の行は、送信されたメールアドレスを使って、データベースからユーザーを取り出している。downcaseを使っていることに注意。
次の文について、

user && user.authenticate(params[:session][:password])

論理積である&&は、取得したユーザーが有効かどうかを決定するためにつかう。Rubyではnilとfalse以外がすべてtrueになるので、組み合わせ表は以下の通りになる。

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

入力されたメールアドレスを持つユーザーがデータベースに存在するかつ、入力されたパスワードがそのユーザーのパスワードである時のみ、if文がfalseになる。

フラッシュメッセージを表示する

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
      flash[:danger] = 'Invalid email/password combination' # 本当は正しくない
      render 'new', status: :unprocessable_entity
    end
  end

  def destroy
  end
end

どこが間違っているかというと、このコードのままではリクエストのフラッシュメッセージが一度表示されると消えずに残ってしまう。renderで再レンダリングしてもリクエストとみなされないので消えない。ここは今後修正する。

フラッシュのテスト

rails g integration_test users_login

テストの基本的な流れ
1.ログイン用のパスを開く
2.新しいセッションのフォームが正しく表示されたことを確認する
3.わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
4.新しいセッションのフォームが正しいステータスを返し、再度表示されることを確認する
5.フラッシュメッセージが表示されることを確認する
6.別のページ(Homeページなど) にいったん移動する
7.移動先のページでフラッシュメッセージが表示されていないことを確認する

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_response :unprocessable_entity
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
end

この時点でテストはred。
解決策としてはflash.nowを使う。これを使えばメッセージはその後のリクエストが発生した時に消滅する。

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
      flash.now[:danger] = 'Invalid email/password combination' # ←ここ!
      render 'new', status: :unprocessable_entity
    end
  end

  def destroy
  end
end

テストはgreenになる。

ログイン

次は有効なフォームの扱い。今回はcookiesを使うが、ブラウザを閉じると自動的に有効期限が切れるものを使う。

ログイン機構を実装するために、これから複数のコントローラからログイン関連のメソッドを呼び出せるようにするが、こんな時に便利なのがヘルパーの仕組み。各ヘルパーはコントローラを生成したときに自動的に用意されるので、あとは「どこから呼び出せるようにしたいか」を決めるだけ。

これから実装するログイン機構はいろんなとこで使うので、全コントローラの親クラスである、「applicationコントローラ」に自動生成されたセッション用ヘルパーを読み込ませ、どのコントローラからでもログイン関連のメソッドを呼び出せるようにする。

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include SessionsHelper
end

log_inメソッド

session[:user_id] = user.id

上のコードを実行すると、ユーザーのブラウザ内の一時cookiesに暗号化済み(盗み出されてもログインされない)のユーザーIDが自動で作成される。この後のページでsession[:user_id]を使ってユーザーIDを一時的に取り出せる。一方でcokkiesメソッド(今後出てくる)とは対照的に、sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。

いろんなとこで使いまわせるように、Sessionsヘルパーにメソッドを定義する。

app/helpers/sessions_helper.rb

module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
end

セッションはセッションハイジャックに対して脆弱。セッションハイジャックとは、攻撃者があるユーザーのセッションidのコピーを手に入れ、そのユーザーとしてログインする攻撃方法。

またセッション固定という攻撃も危険で、これは攻撃者が既に持っているセッションidをユーザーに使わせるように仕向けることで、攻撃者がユーザーとセッションを共有するというもの。対策としては、ユーザーがログインする直前にセッションを必ず即座にリセットすること。reset_sessionメソッドで対策可能。

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])
      reset_session      # ログインの直前に必ずこれを書くこと
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new', status: :unprocessable_entity
    end
  end

  def destroy
  end
end

現在のユーザー

今度は保存したユーザーIDを別のページで取り出す。そのためにはcurrent_userメソッドを定義し、セッションIDに対応するユーザー名をデータベースから取り出せるようにする。current_userメソッドの目的は、次のようなコードが書けること

<%= current_user.name %>

また、次のようなコードで簡単にユーザープロフにリダイレクトできる

redirect_to current_user

どう取り出すかで、真っ先に思いつくのがfindメソッドを使うこと

User.find(session[:user_id])

しかしユーザーIDが存在しない状態でfindを使うと例外が発生する。「ユーザーがログインしていない」などの状況が考えられる今回のケースでは、session[:user_id]の値がnilになる可能性もある。なので今回はfind_byを使う。

User.find_by(id: session[:user_id])

これで、IDが無効な場合(=ユーザーが存在しない場合)にもメソッドは例外を発生せず、nilを返す。

def current_user
  if session[:user_id]
    User.find_by(id: session[:user_id])
  end
end

nilを返してほしい理由としては、user_currentメソッドが1リクエスト内の処理で何度も呼び出されると、呼び出された回数と同じだけデータベースへの問い合わせが発生してしまうから。

またRubyの慣習にしたがい、find_byの実行結果をインスタンス変数に保存する工夫もしている。これでデータベースへの問い合わせが最初の一回で済み、以降の呼び出しではインスタンス変数の結果を再利用する。これをメモ化という。

if @current_user.nil?
  @current_user = User.find_by(id: session[:user_id])
else
  @current_user
end

これを1行にすると

@current_user = @current_user || User.find_by(id: session[:user_id])

もっと簡略化

@current_user ||= User.find_by(id: session[:user_id])

ここで重要なのが、Userオブジェクトそのものの論理値は常にtrueになること。そのおかげで@current_userの何も代入されていない時だけfind_byメソッドを実行し、無駄なデータベースへの読み出しが行われなくなる。
最終結果として、

app/helpers/sessions_helper.rb

module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # 現在ログイン中のユーザーを返す(いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end
end

レイアウトリンクを変更する

ログインしてる時には、ユーザー一覧やらユーザー設定やらログアウトやらをnavに追加する。
まずは統合テストを書く。

<% if logged_in? %>
  # ログインユーザー用のリンク
<% else %>
  # ログインしていないユーザー用のリンク
<% end %>

上記のコードを書くために、まずはlogged_in?メソッドの定義から。
ユーザーがログイン中の状態とは「sessionにユーザーidが存在している」こと、つまりcurrent_userがnilではないということ。

app/helpers/sessions_helper.rb

module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # 現在ログイン中のユーザーを返す(いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in? # ←ここ!
    !current_user.nil?
  end
end

この時点でいろいろレイアウトが書き換えられるように

app/views/layouts/_header.html.erb

<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %> ←ここと
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" id="account" class="dropdown-toggle">
              Account <b class="caret"></b>
            </a>
            <ul id="dropdown-menu" class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li> ←ここと
              <li><%= link_to "Settings", '#' %></li> ←ここと
              <li class="divider"></li>
              <li> ↓ここと
                <%= link_to "Log out", logout_path,
                                       data: { "turbo-method": :delete } %>
              </li>
            </ul>
          </li>
        <% else %> ←ここ!
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

注目点
ログアウトではHTTPのDELETEを使う
Profileではリンクにcurrent_userメソッドを使う(user_path(current_user)が省略されてこの書き方に)

メニューのtoggle機能

navのaccountが表示されたりされなかったりするやつ。これはJSで行うが、まずRaulsプロジェクトにJSを導入するという課題がある。現在ではImportmapと呼ばれる手法によって標準化された。ImportmapとTurboとStimulusをインストールする。後者2つはHotwireフレームワークの一部。

$ rails importmap:install turbo:install stimulus:install

通常はrails newをした時点で入っているが、今回は--skip-bundleをしたので手動で追加。上記のコマンドを実行するとJSのマニフェストファイルである、manifest.jsや、アプリケーションのレイアウトファイルapplication.html.erbなど、様々なファイルが自動的に更新される。

Accountのドロップダウンメニュー戦略
1.CSSのactiveクラスの振る舞いを定義して、メニューが表示されるようにする
2.JavaScriptを置くためのcustomディレクトリとmenu.jsファイルを新たに作成する
3.menu.jsファイルにtoggleコードを書く
4.Importmapを使って、customディレクトリが存在することをRailsに認識させる
5.menu.jsをapplication.jsにインポートする

1 CSSのactiveクラスの振る舞いを定義して、メニューが表示されるようにする

app/assets/stylesheets/custom.scss
.
.
.
/* Dropdown menu */

.dropdown-menu.active {
  display: block;
}

2 JavaScriptを置くためのcustomディレクトリとmenu.jsファイルを新たに作成する

$ mkdir app/javascript/custom
$ touch app/javascript/custom/menu.js

3 menu.jsファイルにtoggleコードを書く

app/javascript/custom/menu.js

// メニュー操作

// トグルリスナーを追加してクリックをリッスンする
document.addEventListener("turbo:load", function() {
  let account = document.querySelector("#account");
  account.addEventListener("click", function(event) {
    event.preventDefault();
    let menu = document.querySelector("#dropdown-menu");
    menu.classList.toggle("active");
  });
});

4 Importmapを使って、customディレクトリが存在することをRailsに認識させる

config/importmap.rb

# Pin npm packages by running ./bin/importmap

pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/custom",      under: "custom" # ←ここ

5 menu.jsをapplication.jsにインポートする

app/javascript/application.js

// Configure your import map in config/importmap.rb.
// Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "custom/menu" //←ここ

レスポンシブデザイン

別途勉強するべし

レイアウトの変更をテストする

統合テストの手順
1.ログイン用のパスを開く
2.セッション用パスに有効な情報をPOSTする
3.ログイン用リンクが表示されなくなったことを確認する
4.ログアウト用リンクが表示されていることを確認する
5.プロフィール用リンクが表示されていることを確認する

上の変更を確認するためには、テスト時に登録済みユーザーとしてログインしておく必用がある。Railsではこのようなテストを、fixture(フィクスチャ)で作成できる。これを使ってテスト用データをtestデータベースに読み込める。

ユーザーには有効な名前と有効なメールアドレスを設定する。テスト中にそのユーザーとして自動ログインするために、そのユーザーの有効なパスワードも用意して、Sessionsコントローラのcreateアクションに送信されたパスワードと比較できるようにする必用がある。password_digest属性をユーザーのfixtureに追加すればいいので、digestメソッドを独自に定義する

has_secure_passwordでbcryptパスワードが作成されるので、同じ方法でfixture用のパスワードを作成する。Railsのsecure passwordのソースコードを調べると、次の部分でパスワードが生成されることがわかる。

BCrypt::Password.create(string, cost: cost)

stringはハッシュ化する文字列、costはコストパラメータと呼ばれる値。ハッシュを算出するための計算コストを指定する。テスト中は軽くていいので

cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                              BCrypt::Engine.cost

上記の意味は、テスト中はコストパラメータを最小にして、本番環境では通常の高いコストで計算する。

今後digestメソッドは再利用するので、Userモデルのuser.rbに置いておく。この計算はユーザーごとに行う必要はないので、fixtureファイルなどでわざわざユーザーオブジェクトにアクセスする必要はない。つまりインスタンスメソッドで定義する必要はないので、digestメソッドをUserクラス自身に配置し、クラスメソッドにすることにする。

app/models/user.rb

class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
end

結果としてfixtureは

test/fixtures/users.yml

michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

ハッシュ化されていない生のパスワードもあると便利。しかしそれを参照する機能はfixtureにはないので、テスト用のfixtureでは全員同じパスワード「password」を使うことにする。
有効なユーザーのfixtureを作成できたので、次のように参照できるようになる。

user = users(:michael)

usersはfixtureのファイル名users.ymlを表し、:michaelというシンボルはユーザーを参照するためのキー。

test/integration/users_login_test.rb
require "test_helper"

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  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
end

テストはgreen

ユーザー登録時にログインする

Usersコントローラのcreateアクションにlog_inを追加するだけ。また、セッション固定攻撃から保護するためのreset_sessionも記述。

app/controllers/users_controller.rb

class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      reset_session # ←ここと
      log_in @user # ←ここ!
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new', status: :unprocessable_entity
    end
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end

動作をテストするために、ユーザーがログイン中かどうかをチェック。is_logged_in?ヘルパーメソッドを定義していると便利。このヘルパーメソッドは、テストのセッションにユーザーがあればtrueを返し、それ以外の場合はfalseを返す。
ヘルパーメソッドはテストから呼び出せないので、current_userを呼び出せない。sessionメソッドはテストでも使えるので、これを代わりに使う。

test/test_helper.rb

ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  # 指定のワーカー数でテストを並列実行する
  parallelize(workers: :number_of_processors)
  # test/fixtures/*.ymlのfixtureをすべてセットアップする
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in? # ←追加
    !session[:user_id].nil?
  end
end
test/integration/users_signup_test.rb

require "test_helper"

class UsersSignupTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "valid signup information" do
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in? # ←追加
  end
end

ログアウト

ログアウトの処理はdestroyアクション。以下のようにセッションのdeleteメソッドでuser idを消すだけ。

session.delete(:user_id)

しかし、これよりもreset_sessionメソッドで、すべてのセッション変数を確実にリセットする方がいい。

app/helpers/sessions_helper.rb

module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 現在のユーザーをログアウトする
  def log_out # ←ここ
    reset_session
    @current_user = nil   # 安全のため
  end
end
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])
      reset_session
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new', status: :unprocessable_entity
    end
  end

  def destroy
    log_out # ←ここと
    redirect_to root_url, status: :see_other # ←ここ
  end
end

status: :see_otherに注目。RailsでTurboを使う時は、このように303 SeeOtherステータスを指定することで、DELETEリクエスト後のリダイレクトが正しく振る舞うようにする必用がある。

ログアウト機能のテストのため若干追加。

test/integration/users_login_test.rb

require "test_helper"

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  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_not is_logged_in? # ←ここと
    assert_response :unprocessable_entity
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end

  test "login with valid information followed by logout" do # ←ここと
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in? # ←ここと
    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)
    delete logout_path # ←ここと
    assert_not is_logged_in? # ←ここと
    assert_response :see_other # ←ここと
    assert_redirected_to root_url # ←ここと
    follow_redirect! # ←ここと
    assert_select "a[href=?]", login_path # ←ここと
    assert_select "a[href=?]", logout_path,      count: 0 # ←ここと
    assert_select "a[href=?]", user_path(@user), count: 0 # ←ここ
  end
end

テストはgreen。長いので後々分割予定

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?