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章まとめ

Posted at

目的

ユーザーがログインやログアウトを行えるようにする

8.1.1 Sessionsコントローラ

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

・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

テストコード

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest

  test "should get new" do
    get login_path
    assert_response :success
  end
end

・rails routes
全ルーティングを表示できる

rails routes
   Prefix Verb   URI Pattern               Controller#Action
     root GET    /                         static_pages#home
     help GET    /help(.:format)           static_pages#help
    about GET    /about(.:format)          static_pages#about
  contact GET    /contact(.:format)        static_pages#contact
   signup GET    /signup(.:format)         users#new
    login GET    /login(.:format)          sessions#new
          POST   /login(.:format)          sessions#create
   logout DELETE /logout(.:format)         sessions#destroy
    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

8.1.2 ログインフォーム

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

・form_withヘルパー
セッションにはSessionモデルというものがない、そのため@sessionのようなインスタンス変数に相当するものもない

form_with(url: login_path, scope: :session)

リソースのスコープ(ここではセッション)とそれに対応するURLを具体的に引数に指定する

・ユーザー登録フォームの場合

<%= form_with(model: @user) do |f| %>
  .
  .
  .
<% end %>

・ログインフォームのコードの場合

<% 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>

8.1.3 ユーザーの検索と認証

パスワードとメールアドレスの組み合わせが有効かどうかを判定する

Sessionsコントローラのcreateアクションを作成

class SessionsController < ApplicationController

  def new
  end

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

  def destroy
  end
end

createアクションの中では、ユーザーの認証に必要なあらゆる情報をparamsハッシュから簡単に取り出せる

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

{ session: { password: "foobar", email: "user@example.com" } }のようになっており
params[:session][:email]と指定して値を取得できる

・ユーザーをデータベースから見つけて検証する
user = User.find_by(email: params[:session][:email].downcase)

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

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

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

flash[:danger]で設定したメッセージは自動的に表示される

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

8.1.5 フラッシュのテスト

前回のソースではエラーメッセージが残り続ける問題があったためそれを修正する

テストコードを作成する

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

テストコード

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

プロダクトコード
ログイン失敗時の正しい処理を実装
flash.nowのメッセージはその後リクエストが発生したときに消滅する

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

8.2 ログイン

ログイン中の状態での有効な値の送信をフォームで正しく扱えるようにする
cookiesを使った一時セッションでユーザーをログインできるようにする

ApplicationコントローラにSessionヘルパーモジュールを読み込ませ、どのコントローラからでもログイン関連のメソッドを呼び出せるようにする

class ApplicationController < ActionController::Base
  include SessionsHelper
end

8.2.1 log _inメソッド

・sessionメソッドを使って、単純なログインを行えるようにする
・ユーザーのブラウザ内の一時cookiesに暗号化済みのユーザーIDが自動で作成され、この後のページで、session[:user_id]を使ってユーザーIDを元通りに取り出すことができる。
cookiesメソッドと違い、
sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する

・log_inメソッド

module SessionsHelper

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

・reset_session
セッション固定攻撃のリスクがあるため
ユーザーがログインする直前にセッションを必ず即座にリセットする
※セッション固定攻撃とは攻撃者が既に持っているセッションidをユーザーに使わせるように仕向けることで、攻撃者がユーザーとセッションを共有するというもの

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

8.2.2 現在のユーザー

ユーザーIDを一時セッションの中に安全に置けるようになったので、
そのユーザーIDを別のページで取り出す

current_user
セッションIDに対応するユーザー名をデータベースから取り出せるようにする

<%= current_user.name %>

redirect_to current_user

でユーザーのプロフィールページに簡単にリダイレクトできる

・現在のユーザーを検索する方法
User.find_by(id: session[:user_id])でユーザー情報を取得する
しかしIDが無効な場合(=ユーザーが存在しない場合)にもメソッドは例外を発生せず、nilを返す

・current_userを修正する
セッションにユーザーIDが存在しない場合、このコードは単に終了して自動的にnilを返す

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

・メモ化(memorization)
メソッド呼び出しの結果を変数に保存し、次回以降の呼び出しで再利用する

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

↓短縮系

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

・「||=」(or equals)

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

x = x ● yに該当し、●が||に置き換わっただけ
X = x + yと同じ

・最終系

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

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

ユーザーがログインしているときとそうでないときでレイアウトを変更する

ERBコードの中でif-else文を使用し、条件に応じて表示するリンクを使い分ける

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

・logged_in?メソッド
ユーザーがログインしていればtrue、その他ならfalseを返すメソッド

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

8.2.4 メニューのトグル機能

CSS idを利用してページ内にあるドロップダウンメニューを検索し、
メニューの表示と非表示をJavaScriptでトグルできるようにする

Importmap自身をインストールしてからTurboとStimulusをインストールすれば
RailsプロジェクトにJavaScriptを導入できる

ImportmapとTurboとStimulusをインストールする
rails importmap:install turbo:install stimulus:install

ドロップダウンメニュー実装手順

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

1
custom.scss

.
.
.
/* Dropdown menu */

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

2

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

3

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.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
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"

8.2.5 モバイル向けスタイリング

画面デザインをモバイルデバイス向けに調整する

・ビューポート用のmetaタグを追加する
ビューポートを使うと、開発者がPCモードとモバイルモードを切り替えられるようになる

<!DOCTYPE html>
<html>
  <head>
    <title><%= full_title(yield(:title)) %></title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="utf-8">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    .
    .
    .

・モバイルモードでハンバーガーメニューを使う

_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <div class="navbar-header">
        <button id="hamburger" type="button" class="navbar-toggle collapsed">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
      </div>
      <ul id="navbar-menu"
          class="nav navbar-nav navbar-right collapse navbar-collapse">
        .
        .
        .
      </ul>
    </nav>
  </div>
</header>

・モバイルメニューをトグルするJavaScriptコード
CSSの"active"クラスをトグルするのではなく、Bootstrapで定義されている"collapse"クラスをトグルする

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

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

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

・テスト用データをfixture(フィクスチャ)で作成できる
このfixtureを使って、テストに必要なデータをtestデータベースに読み込んでおくことができる。

・BCrypt::Password.
as_secure_passwordでbcryptパスワードが作成されるので、同じ方法でfixture用のパスワードを作成する
stringはハッシュ化する文字列、costはコストパラメータと呼ばれる値
コストパラメータでは、ハッシュを算出するための計算コストを指定する

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

・fixture向けのdigestメソッドを追加する
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 }

ERBコードでテストユーザー用の有効なパスワードを作成できる

<%= User.digest('password') %>

**ユーザーログインのテストで使うfixture**
users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

テストコード
テスト用のfixtureでは全員同じパスワード「password」を使う。

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

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

ユーザー登録中にログインも済ませる

・Usersコントローラのcreateアクションにlog_inを追加する
ログインの直前にreset_sessionメソッド呼び出しも追加する

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?
テストの為にユーザー登録の終わったユーザーがログイン状態になっているかどうかを確認できるようにするメソッドをhelperに追加する

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

8.3 ログアウト

ログアウト機能を追加する

・ログアウト時にreset_sessionメソッドですべてのセッション変数をリセットする
log_outメソッドにreset_sessionメソッドを実装

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

log_outメソッドは、Sessionsコントローラのdestroyアクションで使える
destroyアクションでユーザーのログアウト処理を実行する

リダイレクト時にstatus: :see_otherというHTTPステータスも指定している

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

テストコード

テスト項目
・deleteメソッドでDELETEリクエストをログアウト用パスに発行

・ユーザーがログアウトしたら、ステータスコード:see_otherによってルートURLにリダイレクトされることを確認

ログイン用リンクが再度表示される

ログアウト用リンクとプロフィール用リンクが非表示になることも確認

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

感想

今回はログインログアウト機能を実装しました
ログイン、ログアウトではsessionメソッドを使用してユーザーのログイン状態を変更する方法を学ぶことができました。
学習しているとrailsの用意されているメソッドで簡単に色々な実装ができることを実感しています!

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?