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 3 years have passed since last update.

rails-tutorial第8章

Last updated at Posted at 2020-06-06

#ログイン機能を作ろう!!

モデルを使わないSessionリソースを扱う。

###Sessionsコントローラを作ろう

$ rails generate controller Sessions new

###ルーティング設定

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

別にresourcesを使ってないからといってrestfulじゃないわけではない。本質は同じurlにhttpリクエストメソッドで指定してアクションを分けるということ。

また、これにより、login_pathなどの名前付きルートが設定される。

###ログインフォーム作る。

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

form_forの引数はsignupの時は@userを渡していたが、:sessionを渡すことにより、
params = {session: {email: ~~~, password: ~~~}}という形で情報を送ることができる。

で、urlオプションに名前付きルートを渡せば完成。

###次にSessionsコントローラのcreateアクションを実装していこう。

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

  def destroy
  end
end

find_byに注目。
find()だと、idでしか探すことができず、()の中に数字しか入れられない。

IDがわかっている場合は、findメソッド
IDが不明で、別の条件でレコード検索をしたい場合は、find_byメソッド
このように覚えておこう。

注意点!!

user = User.find_by(email: params[:session][:email].downcase)
if user.authenticate(params[:session][:password])
.
.
.

上記のように書いてしまうと、致命的な欠陥がある。

find_byはオブジェクトが見つからない時にnilを返すので、もし存在していないメールアドレスを渡してしまうと、、

nil.authenticateとなり、Nomethoderrorが起こってしまう。

なので、

user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])

ユーザーが存在するかつ、アドレスの認証が通れば、という条件をif文に当てている。

ちなみに userが存在しないとわかった時点で、右側の条件式は評価されないという特徴がある。
これにより、nil.authenticateは実行されなくなる。

###ログイン失敗時のメッセージを表示する。

ActiveRecordを継承しているモデルと、バリデーションを設定すれば@user.errors.full_messagesに実際のエラーメッセージが表示されたが、

Sessionsの場合、モデルでもなければバリデーションもないので、

flashを使ってメッセージを表示する必要がある。

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

  def destroy
  end
end

上記の場合、ちょっとしたバグがある。
それは、flashメッセージがhome画面やhelp画面に移動しても表示されてしまうことだ。

なぜこれが起きるのか?

redirect_toの場合は、それでリクエストが一回。
別のページに飛ぼうとすると2回目なので飛ぶ寸前でflashメッセージは消える。

しかし、今回は、
render 'new'となっている。
renderはリクエストには入らないため、
例えば、home画面に飛ぶので一回目、
再度リロードすると、2回目なので消える、という流れになってしまう。

###回帰バグを防ぐためにテストを書こう。

今回もブラウザを行ったり来たりしているテストなので、インテグレーションテストで行う。

$ rails generate integration_test users_login

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

この状態だとテストは落ちる。

じゃあ、どうやってflashメッセージを1回だけ表示するの?

flashはメソッドなので、flash.now[:danger] = 'Invalid email/password combination'
というようにできる。
.nowは1度目のリクエストが来たらflashメッセージを消す。
これはflashメソッドがrailsで元から設定されたメソッドだからこういうことができる。

これで失敗時の実装は完了

###成功した時の処理を実装しよう

ユーザーが存在し、アドレスの認証が通れば、ログインしている状態を作り出さないといけない。

sessionという特殊な変数を使ってそれを実現する。

session[:user_id] = user.id
ここに何か値が入れば、ログイン中とし、nilになればログアウトとする。

それを実現するためにhelperにloginメソッドを定義しよう。

app/helpers/sessions_helper.rb
module SessionsHelper

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

このhelperメソッドを使ってログイン機能を実装

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])
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

最後に、loginしているかどうかは、様々なコントローラで使うので、loginメソッドを書いたhelperをどのコントローラでも使えるようにしておこう。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

そのためには、全コントローラの親クラスであるapplication コントローラに

###createアクションを実装したら

その次は、session[:user_id]に値が入っていれば、〜〜〜〜、入っていなければ、〜〜〜〜というようにしていく。

また、今どのユーザーがログインしているかがわからないと、showアクションでどのユーザーページを表示するべきかなどの問題が発生してしまうので、なんとかして、sessionの情報から現在ログインしているユーザーを参照する必要がある。

では、現在ログインしているユーザーを返すメソッドをhelperに定義しよう。

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

@current_userとなっているのは、インスタンス変数をview側で使いたいから。
また、@current_userを使いたいたびにfind_byをするのはパフォーマンス上あまり良くない。
なので、 ||= を使って、存在すれば@current_userを返す。 なかったら、find_byを使うというようにしている。

これで、1リクエストで最大で1問い合わせにできる。

view側でログインユーザーと非ログインユーザーを分けるには

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

というようにrubyのコードを使ってあげれば良い。

そのために、上記のlogged_in?メソッドを定義しよう。

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

logged_in?メソッドはcurrent_userメソッドを呼び出して、インスタンス変数があれば、trueを返し、なければfalseを返すようにする。

このままだと、nilの時にtrueを返してしまう。

!は否定演算子である。true falseが逆になる。

これで、nilの時はfalseをnilじゃない時はtrueを返すようにする。

###ログインしているかでviewを分ける

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="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul 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, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

ここで注意しなければいけないのは、

<li><%= link_to "Profile", current_user %></li>

これは本来

<li><%= link_to "Profile", user_path(@current_user) %></li>

となっている。

ただ、これは @current_userに省略することができたよね。

だから、current_userメソッドを書くだけで良い。

もう一つ注意する点が、

<%= link_to "Log out", logout_path, method: :delete %>

link_toメソッドはデフォルトではgetリクエストを送るのでmethod: :deleteを書かないと、

/logout に getリクエストを送ってしまう。

なので、link_toでgetリクエスト以外を指定するときは、
method: :deleteのようにオプションを追加しなければいけない。

注意点としては、method: と :deleteどちらも:が必要だということ。

###bootstrapのドロップダウンを使えるようにするために

app/assets/javascripts/application.js
//= require rails-ujs
//= require jquery
//= require bootstrap
//= require turbolinks
//= require_tree .

2,3行目を書き加えるとドロップダウンが使えるようになる。

###ユーザーログインのテスト

テスト時に登録済みユーザーとしてログインしておく必要があります。当然ながら、データベースにそのためのユーザーが登録されていなければなりません。Railsでは、このようなテスト用データをfixture (フィクスチャ) で作成できます。

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

でもこれじゃあdigestメソッドがないから、ハッシュ化できない。

digestメソッドはUserに関連するときしか使わないので、userモデルに定義する。

app/models/user.rb
  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: { case_sensitive: false }
  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

###三項演算子について

condition ? expr1 : expr2

condition
trueかfalseかを評価する式です。
expr1, expr2
各々の値の場合に実行する式です。 conditionがtrueの場合、演算子はexpr1の値を返します。そうでない場合はexpr2の値を返します。

###ユーザーログインテストコード

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
    get login_path
    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

@user = users(:michael)はtest/fixtures/users.ymlで作った:michaelを@userに代入している。

また、

assert_redirected_to @user
follow_redirect!

1行目は、 @userにリダイレクトされますよね?(行き先は〜〜駅ですよね)
2行目は、 assert_redirected_to @userが通った上で@userにリダイレクトされる。

ちなみにこの状態でテストは通る。

###signup後はそのままログイン済みにしよう。

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
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  private

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

log_in @userでセッション変数に@user.idを保存する。
ちなみに、log_in @userが使えるのはsessions_helperがapplication controllerにincludeされてるから。

###ログインのテスト

テストにもテストのhelperが存在する。
そこに、ログインしているかどうかを判断するメソッドを定義しておこう。

test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  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
    get signup_path
    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

先ほどymlにログインしたユーザー、test_helperにis_logged_in?メソッドを定義したので、テストは通る。

###ログアウト機能を実装

ログアウトメソッドもログインメソッドと同じようにsessions_helperに定義しておく。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

ここで気をつけるのが、

sessionというのはハッシュな訳で、session[:user_id] = user.id

session = {user_id: user.id}という形になっている。

で、ハッシュの値を消すときは、

hash.delete(key)

というdeleteメソッドにキーを引数に渡すと実現できる。

で、今回sessionでも同じことが起きている。

session.delete(:user_id)

これにより、sessionというハッシュに格納されていた{user_id: user.id}が消える。

じゃあ、なんで@current_user = nilまでする必要があるの?

これは、なるべくサーバーの問い合わせ?的なものを無くして負荷を少なくするためらしい。

###destroyアクションの実装

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])
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out
    redirect_to root_url
  end
end

ここで注目したいのが、destroyアクションはviewがないので、最後にどのviewに飛ぶかを指定してあげる必要があるということ。これはupdateアクションとかにも言えそうだよね。

###ログアウトのテスト

統合テストのストーリーを拡張しよう。
具体的には、ログインテストの中で、ログアウトのテストのアサーションも書いちゃおうというもの。

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "login with valid information followed by logout" do
    get login_path
    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_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

assert_not is_logged_in?
is_logged_in?はtest_helperに定義したから使える。

###なぜsession変数はなくならない?なくなる?

調べろ。ブラウザとrails sに保存されるから?

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?