#ログイン機能を作ろう!!
モデルを使わないSessionリソースを扱う。
###Sessionsコントローラを作ろう
$ rails generate controller Sessions new
###ルーティング設定
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などの名前付きルートが設定される。
###ログインフォーム作る。
<% 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アクションを実装していこう。
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を使ってメッセージを表示する必要がある。
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
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メソッドを定義しよう。
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
このhelperメソッドを使ってログイン機能を実装
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をどのコントローラでも使えるようにしておこう。
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
include SessionsHelper
end
そのためには、全コントローラの親クラスであるapplication コントローラに
###createアクションを実装したら
その次は、session[:user_id]に値が入っていれば、〜〜〜〜、入っていなければ、〜〜〜〜というようにしていく。
また、今どのユーザーがログインしているかがわからないと、showアクションでどのユーザーページを表示するべきかなどの問題が発生してしまうので、なんとかして、sessionの情報から現在ログインしているユーザーを参照する必要がある。
では、現在ログインしているユーザーを返すメソッドをhelperに定義しよう。
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?メソッドを定義しよう。
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を分ける
<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のドロップダウンを使えるようにするために
//= require rails-ujs
//= require jquery
//= require bootstrap
//= require turbolinks
//= require_tree .
2,3行目を書き加えるとドロップダウンが使えるようになる。
###ユーザーログインのテスト
テスト時に登録済みユーザーとしてログインしておく必要があります。当然ながら、データベースにそのためのユーザーが登録されていなければなりません。Railsでは、このようなテスト用データをfixture (フィクスチャ) で作成できます。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
でもこれじゃあdigestメソッドがないから、ハッシュ化できない。
digestメソッドはUserに関連するときしか使わないので、userモデルに定義する。
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の値を返します。
###ユーザーログインテストコード
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後はそのままログイン済みにしよう。
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が存在する。
そこに、ログインしているかどうかを判断するメソッドを定義しておこう。
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
先ほどymlにログインしたユーザー、test_helperにis_logged_in?メソッドを定義したので、テストは通る。
###ログアウト機能を実装
ログアウトメソッドもログインメソッドと同じようにsessions_helperに定義しておく。
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アクションの実装
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アクションとかにも言えそうだよね。
###ログアウトのテスト
統合テストのストーリーを拡張しよう。
具体的には、ログインテストの中で、ログアウトのテストのアサーションも書いちゃおうというもの。
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に保存されるから?