ログイン機能
HTTPリクエスト(POST、GETなど)は送信後、リクエストを送信したという記録が残らないため、情報を保存することができない。
ログイン機能を実装するために、sessionメソッドと専用のコントローラを用意する。
Sessionsコントローラ
Sessionsコントローラを作成する。
$ rails generate controller Sessions new
newアクションとビューはログイン画面で使用する。
SessionsコントローラにはUsersコントローラと同様、RESTfulなルーティングを設定する。
resourcesメソッドを使ってもよいが、editやshowなどのアクションは不要なので、必要なルーティングだけを手動で設定する。
Rails.application.routes.draw do
.
.
.
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
resources :users
end
ルーティングは次のような状態になっている。
Sessionsコントローラ内に、空のcreateアクションとdestroyアクションを作っておく。
また、Sessionsコントローラ用のテストを名前付きルートで書き直しておく。
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
get login_path
assert_response :success
end
end
ログインフォーム
新規登録フォームと同様に、ログインフォームを作成する。
フォームフィールドはメールアドレスとパスワード用だけでよい。
<% 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メソッドの第一引数は:sessionとなっている(次項)。
ログイン失敗時の処理
ログイン失敗時の処理を書いていく。
ログインフォームから送信された情報は、新規登録の時と同様に、params変数にハッシュとして入っている。
params = { session: { email: "foo@bar.com", password: "foobar" } }
よって、キーを使うことでデータにアクセスできる。
params[:session][:email]
params[:session][:password]
これを使ってcreateアクションを作ると次のようになる。
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
# ユーザーログイン後にユーザー情報のページにリダイレクトする
else
# エラーメッセージを作成する
render 'new'
end
end
まず、find_byメソッドを使ってフォームに入力されたメールアドレスと一致するユーザーを探す。
ここで取得したUserオブジェクトは他のところでは使わないので、@付きのインスタンス変数にする必要はない。
また、メールアドレスは全て小文字で保存されているので、downcaseメソッドを使うことを忘れないようにする。
次に、ユーザーが見つかれば、has_secure_passwordのauthenticateメソッドを使って、送信されたパスワードが正しいかどうかチェックする。
ユーザーが存在し、かつパスワードが合っていれば、ログインしてユーザー情報ページにリダイレクトする。
ユーザーが存在しない、またはパスワードが間違っていれば、エラーメッセージを表示して、ログインページに戻る。
エラーメッセージ
ユーザーの新規登録時には、エラーメッセージは@user.errors.full_messagesに配列として入っており、それをeachメソッドを使って取り出して表示していた。
ログインの場合にはUserオブジェクトのようなものが無いので、flashメソッドを使って表示する。
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
このフラッシュによるエラーメッセージにはバグがある。
フラッシュはページを移動したり再読み込みすると消えるのだが、この場合は直後に再レンダリングしていることが原因で、メッセージが表示されたままになってしまう。
これをテスト駆動開発で解決していく。
ログイン失敗時のテスト
ログイン機能用のテストを作成する。
$ rails generate integration_test sessions_users_login
Sessionsコントローラ用のテストなのに、users_loginという名前は紛らわしいと思ったので、チュートリアルから少し変えている。
フラッシュがちゃんと消えているかも含め、ログイン失敗時のテストを書く。
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
手順は次のよう。
①ログインページに移動する。
②ログインページが表示されていることを確認する。
③無効なユーザー情報を持つparamsハッシュを送信する。
④ログインに失敗したために、ログインページが再レンダリングされたことを確認する。
⑤フラッシュによるエラーメッセージの存在を確認する。
⑥適当にページを移動する(今回はルートURL)。
⑦フラッシュによるエラーメッセージが消えていることを確認する。
最後の⑦のところで実際はフラッシュが消えずに残っているので、テストはREDになる。
これを解決するために、flashをflash.nowに書き換える。
flash.now[:danger] = 'Invalid email/password combination'
flash.nowを使うと、再レンダリングしたページでのみフラッシュが表示されるようになる。
テストがGREENとなることを確認しておく。
ログイン成功時の処理
sessionsヘルパーメソッド
ログイン機能に必要なメソッドは、Sessionsコントローラを作成した際に自動で生成される。
これをApplicationコントローラで読み込んで、どのコントローラでも使えるようにしておく。
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
include SessionsHelper
end
log_inメソッド
sessionメソッドの中にハッシュとしてUserオブジェクトのidを入れることで、ログイン機能を実装できる。
session[:user_id] = user.id
これは様々なところで使うので、Sessionsコントローラのヘルパーメソッドとして作成する。
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
これを使ってcreateアクションを書き換えると、次のようになる。
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
なお、redirect_to userはredirect_to user_url(user)としてもよい。
現在ログインしているユーザー
現在ログインしているユーザー、つまりsession[:user_id]に入っているidを持つユーザーを取得するために、current_userを定義する。
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
ログインしている場合、session[:user_id]とidが一致するユーザーをfind_byメソッドで探して返す。
ここでは||=(or equals)という代入演算子が用いられている。
以下の二文は同じであり
@foo = @foo || "bar"
@foo ||= "bar"
これは「変数の値がnilなら変数に代入するが、nilでなければ代入しない (変数の値を変えない)」という操作である。
>> @foo
=> nil
>> @foo = @foo || "bar"
=> "bar"
>> @foo = @foo || "baz"
=> "bar"
変数@fooに何も入っていない場合、右辺の値が代入される。
何か入っている場合、何も代入されない。
このcurrent_userが呼び出されるたびに、ユーザーを探して@current_userに代入する、という無駄な処理を省く。
ログイン時のレイアウト
ログインしている場合と、ログインしていない場合とで、レイアウトに表示するものを変更する。
例えば、すでログインしている場合は新規登録やログインページへのリンクは不要である。
また、ログインユーザーのみがアクセスできるページへのリンクを表示する。
これは埋め込みRubyとif文を使って次のように書ける。
<% if logged_in? %>
# ログインユーザー用のリンク
<% else %>
# ログインしていないユーザー用のリンク
<% end %>
logged_in?メソッド
ここで、ログインしているならばtrueを返し、そうでないならばfalseを返すlogged_in?ヘルパーメソッドを作成する。
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>
ユーザーのプロフィールページへのリンクは以下のようであり、link_toメソッドの第二引数のcurrent_userは、やはりuser_path(current_user)とも書ける。
<li><%= link_to "Profile", current_user %></li>
ログアウト用のリンクは以下のようであり、第三引数にmethod: :deleteを渡すことで、DELETEリクエストを送信できるようにしている。
<%= link_to "Log out", logout_path, method: :delete %>
ログイン成功時のテスト
digestメソッドとテスト用ユーザー
ログイン機能をテストするために、有効なユーザー情報を持つテスト用のユーザーをfixtureファイルに用意する。
実際のアプリケーションでは、パスワードはbcryptによってハッシュ化されてUserモデルのpassword_digestカラムに保存される。
テストでこれを再現するために、文字列をbcryptと同じ方法でハッシュ化するdigestメソッドを作成する。
class User < ApplicationRecord
.
.
.
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
内容の理解は不要。
digestメソッドは個々のUserオブジェクトに対して使うものではないので、インスタンスメソッドではなくクラスメソッドで定義している。
テスト用のfixtureファイルは以下のようになる。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
fixtureファイルは全ての行を一段インデントしておかないと謎のエラーを吐くので注意。
また、埋め込みRubyが使用できる。
テスト
有効なユーザー情報でログインし、成功するテストを書く。
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with valid information" do
get login_path
assert_template 'sessions/new'
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
setupでfixtureファイルのユーザーを@userに入れておく。
fixtureファイルのユーザーはusers(ユーザー名のキー)で取得できる。
このusersはusers.ymlのusersであり、Userモデルは関係ない。
また、users[:michael]と書くのは間違いなので注意する。
テストの手順は以下のよう。
①ログインページに移動し、表示されているか確認する。
②有効なユーザー情報を持つparamsハッシュを送信する。
③ログインが成功し、ユーザーのプロフィールページにリダイレクトすることを確認する(assert_redirected_toを使う)。
④リダイレクト先に移動する。
⑤ユーザーのプロフィールページが表示されていることを確認する。
⑥ログインページへのリンクが消えていることを確認する。
⑦ログアウトページとプロフィールページへのリンクが表示されていることを確認する。
ユーザー登録時にログインする
ユーザーが新規登録をした場合に自動でログインするようにする。
Usersコントローラのcreateアクションで、新規登録に成功した場合の処理にlog_inメソッドを追加する。
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?メソッド
ユーザーの新規登録後に自動でログインできているかのテストを書く。
ここで、ログインしているかどうかを論理値で返すis_logged_in?ヘルパーメソッドを作成する。
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
include ApplicationHelper
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
end
このis_logged_in?ヘルパーはSessionsコントローラのlogged_in?ヘルパーメソッドと動作は同じであるが、名前が違う。
アプリケーション用のヘルパーとテスト用のヘルパーは同じ機能でも名前を変えておかないとトラブルの原因になることがあるらしい。
ところで、logged_in?メソッドではcurrent_userを使ってログインを確かめていたが、こちらではcurrent_userが使えないので、sessionを使っている。
(むこうでsessionを使ってもよいと思う)
テスト自体は、ユーザー登録用のテストに一行追加するだけで済む。
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?
assert_not flash.empty?
end
end
ログアウト機能
log_outメソッド
ログアウトするための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
sessionの値を削除するためには、session.delete(:user_id)とする。
session[:user_id] = nilでもよい(多分)。
current_userも忘れずにnilにしておく。
Sessionsコントローラのdestroyアクションでlog_outメソッドを使用する。
def destroy
log_out
redirect_to root_url
end
ログアウト後はルートURLにリダイレクトする。
ログアウト機能のテスト
ログイン成功時のテストに加筆する形で、ログアウト機能のテストを書く。
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
ログアウトのassert_selectは、ログインの逆になっている。