静的型付け言語原理主義者によるRailsチュートリアル(第8章)

image

第8章 基本的なログイン機構

  • ログインの基本的な仕組みを実装する
    • ここでいうログインの基本的な仕組みとは、ブラウザがログインしている状態を保持し、ユーザーによってブラウザが閉じられた状態を破棄すると言った仕組みのこと

8.1 セッション

  • HTTP はステートレスなプロトコルなため、ユーザーログインの必要な Web アプリケーションでは半永続的な接続であるセッションをコンピュータ間で設定する
  • Rails でセッションを実装する最も一般的方法は、cookie を使用すること
    • session という Rails メソッドで一時セッションが作成可能
      • 一時セッションはブラウザを閉じると自動的に終了する
  • セッションを RESTful なリソースとしてモデリングできると、他の RESTful リソースと統一的に理解できて便利
    • ログインページでは new で新規セッションを出力
    • そのページでログインすると create でセッションを実際に作成して保存
    • ログアウトすると destroy でセッションを破棄

8.1.1 Sessions コントローラ

  • ログインとログアウトの要素を、Sessions コントローラの特定の REST アクションにぞれぞれ対応付ける
    • ログインのフォームは new アクションで処理
    • create アクションに POST リクエストを送信すると実際にログインする
    • destroy アクションに DELETE リクエストを送信すると、ログアウトする
  • まずは Sessions コントローラと new アクションを生成
  • リソースを追加して標準的な RESTful アクションを get できるようにする
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
  • Sessions コントローラのテストで名前付きルートを使うようにする
require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest

  test "should get new" do
    get login_path
    assert_response :success
  end
end
  • rails routes コマンドで全ルーティングが出力可能
    • このコマンドが業務で少しだけ触ったことがある
      • 今なら Controller#Action の意味がよく分かる

8.1.2 ログインフォーム

  • ログインフォームを整える
    • ログインフォームとユーザー登録フォームは似ている
    • ログインに失敗したときに表示するエラーメッセージはフラッシュメッセージで対応する
  • Rails では下記コードだけで「フォームの action は /users という URL への POST である」を自動的に判定する
form_for(@user)
  • しかしセッションの場合は Sessions モデルが存在しないので、リソースの名前とそれに対応するURLを具体的に指定する必要がある
form_for(:session, url: login_path)
  • 適切な form_for を使うことで、ログインフォームを簡単に作成できる
<% 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>

8.1.3 ユーザーの検索と認証

  • ログインでセッションを作成する場合に最初に行うのは、入力が無効な場合の処理
  • 最小限の create アクションを Session コントローラで定義し、空の new アクションと destroy アクションもついでに作成しておく
class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new'
  end

  def destroy
  end
end
  • /sessions/new フォームで送信する params ハッシュでは sessions キーの下にメールアドレスとパスワードがある
    • create アクションの中ではユーザーの認証に必要なあらゆる情報を params ハッシュから取り出せる
  • 以上を踏まえてユーザーをデータベースから見つけて検証する
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
  • DBから取り出したメールアドレスを downcase で有効なメールアドレスに確実にマッチすうようにするのは Rails では定番の手法

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

  • ログインに失敗したときにはフラッシュメッセージを表示する
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
  • 上記コードにはフラッシュメッセージが一度表示されると残り続けるという問題がある
    • Home ページに移動しても残っている

8.1.5 フラッシュのテスト

  • フラッシュメッセージが消えないのはバグなのでまずはテストを書く
    • ログイン用のパスを開く
    • 新しいセッションのフォームが正しく表示されたことを確認する
    • わざと無効な params ハッシュを使ってセッション用パスに POST する
    • 新しいセッションのフォームが再度送信され、フラッシュメッセージが追加されることを確認する
    • 別のページにいったん移動する
    • 移動先のページでフラッシュメッセージが表示されていないことを確認する
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
  • 上記テストがこれだけのコードで確認できるのはやはりすごい…
  • flashflash.now に変更すればレンダリングが終わっているページで特別にフラッシュメッセージを表示できる
    • flash.now のメッセージはその後のリクエストが発生したときに消滅する

8.2 ログイン

  • 実際にログイン中の状態での有効な値の送信をフォームで正しく扱えるようにする
  • cookie を使った一時セッションでユーザーをログインできるようにする
    • ブラウザを閉じると自動的に有効期限が切れるもの
  • Session コントローラを生成した時点ですでにセッション用ヘルパーモジュールも自動生成されている
    • Rails のセッション用ヘルパーはビューにも自動的に読み込まれる
    • Rails の全コントローラの親クラスである Application コントローラにこのモジュールを読み込ませれば、どのコントローラでも使えるようになる
      • 安易な共有だ
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

8.2.1 log_in メソッド

  • Rails で事前定義済みの session メソッドを使って、単純なログインを行えるようにする
    • session メソッドはハッシュのように扱える
session[:user_id] = user.id
  • 上記コードを実行すると、ユーザーのブラウザ内の一時 cookies に暗号化済みのユーザー ID が自動で作成される
    • どうやっているのか。。。
    • この一時 cookie はブラウザを閉じた瞬間に有効期限が終了する
  • 同じログイン手法を使い回せるように、Sessions ヘルパーに log_in という名前のメソッドを定義する
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
end
  • session メソッドで作成した一時 cookie は自動的に暗号化される
    • 一時 cookie なので攻撃者に盗まれてもそれを使って本物のユーザーとしてログインすることは不可能
  • ユーザーログインを行ってセッションの create アクションを完了し、ユーザーのプロフィールページにリダイレクトする

8.2.2 現在のユーザー

  • 一時セッション内にあるユーザー ID を別のページで取り出す
    • current_user メソッドでDBからユーザー名を取り出す
def current_user
  if session[:user_id]
    User.find_by(id: session[:user_id])
  end
end
  • セッションにユーザー ID が存在しない場合、上記コードは nil を返す
    • DBへの問い合わせ回数を減らす
  • User.find_by の結果をインスタンス変数に保存
    • 1リクエスト内におけるデータベースへの問い合わせは最初の1回だけとなる
  • ユーザーがログインしているかどうかに応じてアプリケーションの動作を変更するための準備が整った
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 logged_in? %>
  # ログインユーザー用のリンク
<% else %>
  # ログインしていないユーザー用のリンク
<% end %>
  • 上記コードを書くためには論理値を返す logged_in? メソッドが必要
  • ユーザーがログイン中の状態とは、session にユーザー id が存在している、すなわち current_usernil でないこと
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
  • ログイン中のユーザー用のレイアウトのリンクを変更する
<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>
  • Bootstrap のドロップダウンメニュー機能を適用できる状態になったので、この機能を有効化するために Rails の application.js に Bootstrap の JavaScript ライブラリを追加する
  • ログインパスにアクセスして有効なユーザーとしてログインできるようになった

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

  • 以下の流れの統合テストを作成する
    • ログイン用のパスを開く
    • セッション用パスに有効な情報を post する
    • ログイン用リンクが表示されなくなったことを確認する
    • ログアウト用リンクが表示されていることを確認する
    • プロフィール用リンクが表示されていることを確認する
  • Rails ではテスト用データを fixture で作成できる
    • digest メソッドを独自に定義し、 password_digest 属性をユーザーの fixure に追加する
  • digest メソッドは User モデルにクラスメソッドとして追加する
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: { 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
  • 有効なユーザーを表す fixture を作成できるようになった
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  • fixture では ERB を利用できる
<%= User.digest('password') %>
  • 上記コードでテストユーザー用の有効なパスワードを作成できる
  • fixture ではハッシュ化されていない生のパスワードは参照できない
  • テストで fixture のデータを参照するように変更する
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

8.2.5 ユーザー登録時にログイン

  • ユーザー登録中にログインを済ませる
    • Users コントローラの create アクションに log_in を追加するだけ
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
  • テストにおいてユーザーがログイン中かどうかをチェックするため、is_logged_in? メソッドを定義しておく
    • テストのセッションにユーザーがあれば true を返し、それ以外の場合は false を返す
  • ヘルパーメソッドはテストから呼び出せないので、 current_user は使えない
    • session メソッドで代用する
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end
  • 上記コードを使うと、ユーザー登録の終わったユーザーがログイン状態になっているかどうかを確認できる
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

8.3 ログアウト

  • このアプリケーションではユーザーが明示的にログアウトするまではログイン状態を保てなくてはならない
  • ログアウト用リンクは作成済みなのでユーザーセッションを破棄するための有効なアクションをコントローラで作成する
  • RESTful ルールに従い、destroy アクションを作成する
  • ログアウト処理ではセッションからユーザー ID を削除する
    • delete メソッドを実行するだけ
    • 現在のユーザーを nil に設定できる
session.delete(:user_id)
  • Session ヘルパーモジュールに配置する log_out メソッドとして上記コードを定義する
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end
  • ここで定義した log_out メソッドは、Session コントローラの destroy アクションでも使用する
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
  • ログアウト機能をテストするために、ユーザーログインのテストを修正する
    • ログイン後、delete メソッドで DELETE リクエストをログアウト用パスに発行し、ユーザーがログアウトしてルート URL にリダイレクトされたことを確認する
  • 以上でサンプルアプリケーションの基本となるユーザー登録・ログイン・ログアウトの機能が完成

8.4 最後に

  • サンプルアプリケーションのログイン機構を実装した
  • 次はセッションより長くログイン情報を維持する

所感

ついにログインの機構を実装した。session や cookie の話も出てきていよいよ Web アプリケーションの開発をしている感が増してきた。フラッシュメッセージという概念は初耳だったのだが、Rails アプリでは一般的に使われるものなのだろうか?また、ERB の中での条件分岐も一般的なのだろうか?単純な if 文であれば問題ないかもしれないが、少しでも分岐するとカオスなロジックが生まれる土壌になるのではと危惧してしまった。


4745 Words

2020-02-12 12:39 +0000