1
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チュートリアル(第6版) 第8章 基本的なログイン機構

Posted at

#第8章
この章では、ログインやログアウト機能を追加する。また、認証システムや認可モデルも導入する。

##セッション
HTTPはステートレス(状態がない)プロトコルのため、以前の情報を利用できない。つまり、別のページに遷移する時に情報を保持することが出来ない。
そのため、セッション(Session)を使うことで、状態を保持することができる。

Railsでセッションを実装する方法として、cookiesを使うのが一般的。

Q:cookiesとは?
A:ブラウザに保存される小さなテキストデータのこと。そのため、別ページに遷移しても情報が保持される。

セッションを利用するには、こんな下記のように
・ログインページで、新しいセッションを出力し、ログインするとセッションを実際に作成し保存。

・ログアウトするとセッションを破棄する。

こんな感じでやる。

###Sessionsコントローラ
セッションも、Sessionsコントローラを作成して、特定のRESTアクションにそれぞれ結びつけることにする。
Sessionsコントローラー作成

$ rails generate controller Sessions new

なお、newアクションを生成すると、それに対応したビューも同時に作られるのは知っての通りだが、無駄なビューを作成しないため、newだけ指定する。

resourcesメソッドを使ってフルセットを利用しないため、名前付きルーティングだけを使う。

config/routes.rb
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'

次にテストを更新する。

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 セッションの削除(ログアウト)

因みに、rails routesとコマンド実行すると現状のルーティングを確認できる。

演習

  1. GET login_pathは、ログイン用のフォームを受け取り、POST login_pathはフォームの入力値をPOSTリクエストで送信する。

$ rails routes | grep sessions
                         sessions_new GET    /sessions/new(.:format)                                                                  sessions#new
                                login GET    /login(.:format)                                                                         sessions#new
                                      POST   /login(.:format)                                                                         sessions#create
                               logout DELETE /logout(.:format)                                                                        sessions#destroy

###ログインフォーム
ここでは、セッションで使うビュー(ログインフォーム)を整える。
前回のユーザー作成ではActive Recordによって自動生成されるエラーメッセージがあったが、今回扱うセッションはActive Recordオブジェクトがないので、エラーメッセージが表示されない。
なので、実装としては、フラッシュでのエラーを表示する。

今までフォーム実装では、form_withヘルパーを使って、インスタンス変数@userを引数に取ってた。

しかし、ユーザーリソースを作成するわけではなく、セッションを作成するだけなので、セッションにはSessionモデルがない。そのため、セッションフォームを作成するときは下記のようになる。

form_with(url: login_path, scope: :session, local: true)

セッションの場合はリソースのスコープをとURLを明示的に指定する。
scope: :sessionと書いたことで、params[:session]に各種属性が入る。
メールアドレスなら
params[:session][:email]
パスワードなら
params[:session][:email]
に格納される。

因みにユーザー登録フォームのform_withはこんなん
form_with(model: @user, local: true)

ログインフォームのコード

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, local: true) 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>

###ユーザーの検索と認証
ユーサー登録の時と同様に、最初に行うのは、入力が無効な場合の処理を実装する。

Sessionコントローラ(暫定版)

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new'
  end

  def destroy
  end
end

フォームを送信したら、render 'new'でnewページが描画される。

image.png
デバッグ情報にメールアドレスとパスワードが入った。

  session: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
    email: aaa@example.com
    password: aaaaaa

これから分かるように、ネスト構造になっている。
paramsはこんな感じの入れ子ハッシュになっている。
{ session: { password: "foobar", email: "user@example.com" } }

なので、params[:session]の中に

{ password: "foobar", email: "user@example.com" }が入っている。

従って、params[:session][:email]これでデータにアクセスできる。

このような形でデータを取り出せるので、User.find_byメソッドでユーザーを探し、authenticateメソッドでパスワードの認証が出来た場合、セッションを作成するという流れでOK

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

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

①メールアドレスは小文字で保存していたので、送信されたメールアドレスをdowncaseメソッドを使って小文字化する。

find_byメソッドでメールアドレスがあるか検索する。

③メールアドレスがあれば、userに格納

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

④&&は論理積で、ユーザーがいて、パスワードの認証が通ればOKということ。つまりは、どちらも真であるなら、if文の中を実行する。

###フラッシュメッセージを表示する
ログイン失敗時にエラーメッセージを表示する。

flash[:danger] = 'Invalid email/password combination' # 本当は正しくない

このままでは、一度フラッシュメッセージが表示されると消えずに残ってしまう。
renderメソッドで強制的に再レンダリングしているため、リクエストとならない。従って、メッセージが常に表示されたままとなる。

image.png
サンプルページでも表示されたまま

###フラッシュのテスト
フラッシュ問題があったので、ログイン関係の簡単な統合テストを作成する。
いつもの通りTDDで行う。

$ rails generate integration_test users_login

テストコードの手順

①ログイン用のパスを開く
②新しいセッションのフォームが正しく表示されているか
③わざと無効なparamsハッシュを使ってセッション用パスにPOST
④新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されているか
⑤別のページ(Homeページなど)にいったん移動
⑥移動先のページでフラッシュにメッセージが表示されてないことを確認

この内容をテストコードに落とし込む

test/integration/users_login_test.rb
  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

テスト実施

$ rails test test/integration/users_login_test.rb
1 runs, 4 assertions, 1 failures, 0 errors, 0 skips

この問題を解決するには、flashflash.nowに置き換えればOK
flash.nowはリクエストが発生した時に消える。
なので、redirect_toで使うとフラッシュが表示されないので、renderと合わせて覚えるのがよさげかな?

app/controllers/sessions_controller.rb
flash.now[:danger] = 'Invalid email/password combination'

テストしたらOKだった

演習

image.png
エラーメッセージが表示されたので別のページに遷移すると、、、
image.png
エラーメッセージが消えたのでOK

##ログイン
cookiesを使った一時セッションでユーザーをログインできるようにする。このcookiesはブラウザを閉じると自動的に有効期限が切れるものだが、第9章ではブラウザを閉じても保持されるセッションを追加する。

Railsの全コントローラーの親クラスであるApplicationコントローラーにSessionsHelperを読み込ませれば、どのコントローラーでも使えるようになる。

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

###log_inメソッド
sessionメソッドを使って、単純なログインを行えるようにする。
sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。(cookiesメソッドは、対照的に永続性がある)

ログインは様々な場所で使うので、Sessionsヘルパーにlog_inという名前のメソッドを定義する。

app/helpers/sessions_helper.rb
module SessionsHelper

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

sessionメソッドで作成した一時cookiesは自動的に暗号化され、保護される。
また、攻撃者がこのcookies情報を盗み出せたとしても、本物のユーザーとしてログインできない。しかし、これは一時セッションのみしか該当せず、永続的セッションでは断言できない。

log_inというヘルパーメソッドを定義したので、セッションのcreateアクションを完了させて、プロフィールページにリダイレクトさせる。

app/controllers/sessions_controller.rb
    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user
    else

###現在のユーザー
別のページでもユーザーIDを使えるようにするため、current_userメソッドを定義し、セッションIDに対応するユーザー名をデータベースから取り出せるようにする。なお、current_userメソッドの目的は、次のようなコードを書く為
<%= current_user.name %>
これで現在のユーザー名を表示できる。
また、プロフィールページにリダイレクトさせることも
redirect_to current_user

ユーザーIDが存在しない状態でfindメソッドを使うと例外が発生するため、find_byメソッドを使うことにする。

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

次に、IDが無効な場合(ユーザーが存在しない場合)にも例外を発生させず、nilを返すので、current_userを定義しなおす。

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

セッションにユーザーIDが存在しない場合、nilが返ってくるのがポイント。
current_userが呼び出された回数分だけ、データベースへ問い合わせが発生する。
なので、User.find_byで得た実行結果をインスタンス変数に格納すれば、最初の一回のリクエストだけになる。以後の呼び出しでは、インスタンス変数の結果を再利用すればOK

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

or演算子である「||」を使えば、下のように書き換えられる。

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

とてもすっきりしたコードになった。さらにこのコードは、@current_userに何も代入されていなければ、find_byを呼び出すため、無駄にデータベースへの読み出しが行われない。

さらに短縮形の書き方がある。

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

この書き方は、プログラミングやっていたらよく見かけるだろう。
x += 1とかでおなじみの奴だ。元は、x = x + 1
さらにいいことは、論理値の||は左から順に評価していくのだが、trueになった時点で処理を終える。また、論理値の&&は、左から右に評価していく際に、falseになった時点で処理を終える。

current_userメソッドに適用させると以下のコードになる。

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

###レイアウトリンクを変更する
ユーザーがログインしている時とログインしていない時で、レイアウトを変更する。
レイアウトのリンクを変更させる方法は、ERBコードの中でif文を使えばOK

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

また、ログインの有無はlogged_in?メソッドを作成して、行う。

ユーザーがログイン中の状態=sessionにユーザーIDが存在している。
つまり、current_usernilでない状態のこと。
チェックには、否定演算子の!を使用する。これで論理値の逆転が起こる。

app/helpers/sessions_helper.rb
  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  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="#" 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>
これは短縮形の書き方で、短縮しないと下記のコードになる。
<%= link_to "Profile", user_path(current_user) %>
どちらもリンク先はshowページになる。

レイアウトに新しいリンクを追加したので、Bootstrapのドロップダウンメニュー機能が適用できる状態になった。
BootstrapにあるCSSのdropdownクラスやdropdown-menuが該当する。
また、bootstrapを有効にするため、application.jsファイルを通して、Bootstrapに同行されているJavaScriptライブラリとjQueryも読み込む。

jQueryとbootstrapを読み込む
$ yarn add jquery@3.4.1 bootstrap@3.4.1

アプリケーションでjQueryを有効にするため、Webpackの環境ファイルを編集、追加する。

config/webpack/environment.js
const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

module.exports = environment

最後にapplication.jsファイルでjQueryをrequireして、Bootstrapをimportする。

app/javascript/packs/application.js
require("jquery")
import "bootstrap"

これでログイン中とログアウトで別々のヘッダーが表示されるようになった。

image.png

image.png

###レイアウトの変更をテストする
先ほどレイアウトの変更を行ったので、今回はその変更に対して統合テストをする。
手順
①ログイン用のパスを開く
②セッション用パスに有効な情報をpostする
③ログイン用リンクが表示されなくなったことを確認する
④ログアウト用リンクが表示されていることを確認する
⑤プロフィール用リンクが表示されていることを確認する

このテストをするには、登録済みユーザーとしてログインしておく必要があるため、テスト用ユーザとしてRailsではテスト用データをfixtureで作成できる。

テストユーザーに有効なパスワードを用意するが、パスワードはpassword_digestにハッシュ化され保存されるため、fixtureでも同様にハッシュ化したパスワードを保存するための、digestメソッドを独自に定義する。

has_secure_passwordでbcryptパスワードが作成されるので、同様の方法でfixuture用のパスワードを作成する。

secure_passwordのソースコードでパスワードが生成されている。

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

stringはハッシュ化する文字列
costはコストパラメータと呼ばれる。これは、ハッシュを算出するのに掛かる計算コストを指定するが、この値を高くすればするほど、より堅牢なパスワードになるが重くなり、低いコストだと簡単だが軽くなる。
 この特性があるため、テストにおいては低いコストの方が軽くて望ましい。
なお、コストを指定するコードは、下のようになる。

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

digestメソッドは、今後様々な場所で利用するため、Userモデルに配置する。
fixtureファイルがわざわざユーザーオブジェクトにアクセスする必要性はないので、クラスメソッドで定義したほうが良い。

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

fixtureファイルにテストユーザーを追記

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

fixtureではERbを利用できるため、digestメソッドにpasswordという文字列を渡して、ハッシュ化された戻り値をpassword_digestに入れるという流れ。
また、テスト用のfixtureにいるユーザーは、全員同じパスワードにするのが一般的。

有効なテストユーザーのfixtureができたら、fixtureのデータを下のように参照できるようにする。
user = users(:michael)
usersは、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
    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

assert_redirected_to @userは、リダイレクト先が@userか。(前回やったやつ)

follow_redirect!は、実際にリダイレクト先に遷移する。

assert_template 'users/show'で、showビューが表示されているか。

assert_select "a[href=?]", login_path, count: 0は、count: 0というオプションを渡すことで、渡したパターンに一致するリンクが0かを確認している。今回は、ログイン後のshowビューなので、ログインパスがない状態、ログアウトパスがある状態、showページへのリンクがある状態としている。

演習
ぼっち演算子
obj && obj.methodという記法があったが、これは短く書ける。
obj&.methodとすればOK
なので
user&.authenticate(params[:session][:password])

user&.authenticate(params[:session][:password])
と書ける。

###ユーザー登録時にログイン
現状は、登録終了時にユーザーがデフォルトでログインしないため、ユーザー登録と同時にログインをできるようにする。

Usersコントローラーのcreateアクションにlog_inメソッドを追記すればOK

  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

ユーザー作成時にログインしているかどうかをチェックするため、is_logged_in?ヘルパーメソッドを定義する。これは、テストのセッションにユーザーがあれば、trueを返し、それ以外はfalseを返す。
しかし、ヘルパーメソッドはテストから呼び出せないため、テストヘルパーにコードを書く。

test/test_helper.rb
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end
test/integration/users_signup_test.rb
  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

ユーザー登録後のログインテストに
assert is_logged_in?
を入れればOK

##ログアウト
ログアウト機能を追加する。
セッションからユーザーIDを削除すれば良い。
deleteメソッドを使って
session.delete(:user_id)
とすればOK

app/helpers/sessions_helper.rb
  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end

ヘルパーモジュールにlog_outメソッドを入れた。

次に、log_outメソッドを呼び出すdestroyアクションを追加する。

app/controllers/sessions_controller.rb
  def destroy
    log_out
    redirect_to root_url
  end

ログアウト後ルートURLにリダイレクトさせる。

次にテストにいくつかテスト項目を追加
・ログインして、deleteメソッドでDELETEリクエストをログアウト用パスに発行し、ユーザーがログアウトしてルートURLにリダイレクトすること
・ログイン用リンクが再表示されること
・ログアウト用リンクとプロフィール用リンクが非表示になる事

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

  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

is_logged_in?ヘルパーメソッドを定義したおかげで、assert is_logged_in?で簡単にテストできるようになった。

#最後に
ログイン機構の実装は、一回で理解しようとせず、二回目あたりから理解していく認識でも十分だと思う。
少しややこしい話になってきたが、より快適に使えるようUXを高めていくことは大事だ。

あとは、GithubとHerokuにあげて終了

1
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
1
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?