###概要
この記事は私の知識をより確実なものにするためにRailsチュートリアル解説記事を書くことで理解を深め
勉強の一環としています。稀にとんでもない内容や間違えた内容が書いてあるかもしれませんので
ご了承ください。
できればそれとなく教えてくれますと幸いです・・・
###この章でやること
前章でユーザーを作成できるようにしたので作成したユーザーでログインする機構を作る。
###セッション
HTTPはステートレス(状態がない)ため以前のリクエスト情報などを利用することができない。
別のページなどに移動した際、データを保持しておく手段がHTTPには存在しない。
代わりに「セッション」を用いることでユーザーの情報を保持しておく。
Railsでこのセッションを実装するためによく使われるのがcookies
cookiesはブラウザに保存されるテキストデータでページを移動しても消えないのでユーザー情報を保持することができる。
####Sessionsコントローラ
SessionもRESTのアクションに直接結びつけて実装する。
具体的には
動作 | アクション |
---|---|
ログインフォーム | new |
ログイン処理 | create |
ログアウト処理 | destroy |
ログイン→一時セッションの作成
ログアウト→一時セッションの削除
まずはセッション用コントローラを作成する。
rails g controller Sessions new
generateコマンドでコントローラを作成すると対応するビューも作成する。
今回ビューが必要なのはログインフォームのnewアクションだけなので
newだけgenerateコマンドで指定する。
routes.rbも更新する。newアクションへのルーティングが定義されているが
書き換える。
get '/login', to:'sessions#new'
post '/login', to:'sessions#create'
delete '/logout', to:'sessions#destroy'
自動生成したテストも名前付きルートを使うよう変更する。
rails routes
コマンドを使うとルーティングの一覧を確認できる。
演習
1.get login_path
はGETリクエストでログイン用フォームを受け取る。
post login_path
はログインフォームで入力した値をPOSTリクエストで送信する。
$ rails routes | grep sessions
login GET /login(.:format) sessions#new
POST /login(.:format) sessions#create
logout DELETE /logout(.:format) sessions#destroy
####ログインフォーム
フォームではform_withを使ってフォームを実装してきた。
ログインフォームはUsersリソースを作成するわけではなくSessionを作成するだけなので
モデルオブジェクトを指定することができない。
このような時は
<%= form_with(url: login_path, scope: :session, local: true) do |f| %>
とすることでlogin_pathにPostリクエストを送る
scope: :sessionとすることで
params[:session]に各種属性の値が格納されるようになる。
例えばpasswordなら
params[:session][:password]に格納される。
#####演習
1.
login_pathは2種類あるがform_withはデフォルトでPOSTリクエストに設定されているため
POSTリクエストをlogin_pathに発行する。つまりPOSTのlogin_pathでcreateアクションに到達する。
GETの場合はnewアクションに到達する。
####ユーザーの検索と認証
今回はログインが失敗した場合の処理を作成する。
def new
end
def create
render 'new'
end
def destroy
end
コントローラーをこのように定義すると
フォームを送信した際そのまままたnewページに転送されるようになる。
paramsハッシュにsessionキーが追加されて、その中にemailキーとpasswordキーが入っていることがわかる。
params = { session: { password: "foobar", email: "take.webengineer@gmail.com" }}
このような構造になっている。
これを用いて
paramsからユーザー認証情報を受け取り、find_by
メソッドでユーザーを検索し
authenticate
メソッドでパスワードを認証したら
sessionを作成するといった流れで処理を行う。
これを踏まえたcreateアクションの内容がこちら
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
#ユーザーページにリダイレクトする処理(これから)
else
#エラーメッセージを作成する。
render 'new'
end
end
&&は論理積の演算子でどちらも真の時だけ真という演算子
userが存在するかつユーザーのパスワードが認証されたときという条件になる。
#####演習
1.期待通りの結果が出る。
>> user = nil
=> nil
>> user && user.authenticate("foobar")
=> nil
>> !!(user && user.authenticate("foobar"))
=> false
>> user = User.first
(1.4ms) SELECT sqlite_version(*)
User Load (0.6ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-14 02:57:10", password_digest: [FILTERED]>
>> !!(user && user.authenticate("foobar"))
=> true
####フラッシュメッセージを表示する
ログインに失敗した時のフラッシュメッセージを作成する。
flash[:danger] = "Invalid email/password combination"
このままだとほかのページに遷移した時もメッセージが残ってしまう。
なぜならrenderでフォームを再表示しているから。
renderはリダイレクトではなくビューの再レンダリングなのでflashが消えない。
これからこの問題点を修正する。
####フラッシュのテスト
ログイン回りの統合テストを作成する。今回はほかのページでもフラッシュが表示されてしまうという問題があるのでこのエラーを
検出するテストを先に作り、テストがパスするようにコードを書く。
今回のコードはこちら
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
最後の2行でルートパスにアクセスし、フラッシュが消えていることをテストしている。
もちろんエラーとなる。
この問題を解決するためにflash.now
を使う。
flash.now
は次のアクションに移行した段階で消える。
↑参考サイト(https://qiita.com/taraontara/items/2db82e6a0b528b06b949)
全部flash.nowでよくね、と一瞬血迷ったがflash.nowの場合はredirect_toを使うとそもそもflashが一度も表示されずに
終わる。(redirect_toで次のアクションを読みに行くため)
さっそくflash
をflash.now
に変える。
この変更でテストはパスする。
#####演習
1.
ログイン失敗
別ページに遷移すると消える
###ログイン
次はログイン情報が有効な際の処理を実装する。
処理の流れは
認証し、ユーザーが正しければcookiesを使った一時セッションを作成し、ログイン処理を完了させ、
該当のユーザーページに遷移させる。
セッションはログインページ以外でもログイン済みかそうでないかによって処理を分けるので
共通で使えるようにSessionsHelperをApplicationControllerにincludeしておく。
こうすることですべてのコントローラで共通で使えるようになる。
####log_inメソッド
Railsで定義しているsession
メソッドを使ってログイン処理を実装する。
sessionメソッドを使うと一時cookiesにキーに紐づけた値が保存される。
この一時cookiesはブラウザが閉じた瞬間に有効期限が終了するため閉じるたびにログインが必要になる。
ログインは様々な場所で使うことが想定されるのでSessionsHelperにメソッドとして定義しておく。
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
#####演習
1.Chromeの場合だとデベロッパーツールのApplicationタブのCookiesで一覧が表示できる。
↑暗号化されたユーザーIDが格納されている(_sample_app_session)
2.Expires→期限が切れる時間が書いてある。一時セッションの場合はSessionとなっており、ブラウザを閉じたときに切れることがわかる。
####現在のユーザー
別のページでもユーザーIDを取り出しユーザー情報を利用できるようにするためにcurrent_user
メソッドを定義する。
セッションに保存されたIDからユーザーオブジェクトを検索するがfindを使うとユーザーが存在しなかった場合にエラーを発生してしまうので
find_byメソッドを使う。
def current_user
if session[:user_id]
User.find_by(id: session[:user_id])
end
end
さらにこの結果(Userオブジェクト)をインスタンス変数に保存することで
1リクエスト内でのデータベース参照を1度で済ませることができ、高速化につながる。
def current_user
if session[:user_id]
if @current_user.nil?
@current_user = User.find_by(id: session[:user_id])
else
@current_user
end
end
end
そして||演算子を使うことでこのif文を1行で書くことができる。
@current_user = @current_user || User.find_by(id: session[:user_id])
さらにこの行を短縮形で書くことで正しいコードになる。
@current_user ||= User.find_by(id: session[:user_id])
#####演習
1.
>> User.find_by(id:"123")
(0.4ms) SELECT sqlite_version(*)
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 123], ["LIMIT", 1]]
=> nil
①nilを与えるとfind_byメソッドはnilを返し@current_userもnilになる。
②@current_user
が空の時にはデータベースから読み出すが中身があるときには自信を代入(何もしない)
>> session = {}
=> {}
>> session[:user_id] = nil
=> nil
>> @current_user ||= User.find_by(id:session[:user_id])
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]]
=> nil
>> session[:user_id] = User.first.id
User Load (0.1ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> 1
>> @current_user ||= User.find_by(id: session[:user_id])
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-14 02:57:10", password_digest: [FILTERED]>
>> @current_user ||= User.find_by(id: session[:user_id])
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-14 02:57:10", password_digest: [FILTERED]>
>>
####レイアウトリンクを変更する
ログインしているときとしていないときで表示するページを動的に変化させる。
ログインしているかしていないかでERB内でif分岐させ、リンクを変化させる。
これに論理値を返すlogged_in?
メソッドが必要になるため定義する。
def logged_in?
!current_user.nil?
end
current_userが空ではないことを確かめたいので!で論理値を逆転させている。
次にヘッダーのレイアウトを変更していく。
<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のクラス(dropdownなど)を使ってドロップダウンメニューを追加している。
また
<%= link_to "Profile", current_user %>
の行では
省略形でリンクを指定しており
リンク先はshowページになる。ユーザーオブジェクトを渡すとshowページへのリンクへ変換してくれる機能である。
ドロップダウンメニューはbootstrap内のjQueryを読み込まなければならないため、
application.jsを通して読み込む。
まずはyarnでjQueryとbootstrapのパッケージを読み込む。
yarn add jquery@3.4.1 bootstrap@3.4.1
次にWebpackの環境ファイルにjQueryの設定を追加する。
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する。
この時点でドロップダウンメニューが有効になり、
有効なユーザーでのログインができるようになっているので
これまでのコードを効率よくテストできる。
またブラウザを閉じると期待通り一時セッションが削除され、再ログインが必要になる。
#####演習
1.ChromeのCookie削除画面
削除したあとログインリンクが表示される。
2.省略。ブラウザを再起動するとログインを求められる。(一時セッションが消える。)
####レイアウトの変更をテストする
動的に変化するヘッダーリンクの動作を統合テストでテストしていく。
動作は
- ログイン用パスにアクセス
- ログイン情報をpost
- ログインリンクが非表示になっていることをテスト
- ログアウトリンクが表示されていることをテスト
- プロフィール用リンクが表示されていることのテスト
これらをテストするためにまず登録されているユーザでログインする必要があるが
テスト用データベースにユーザーが登録されている必要がある。
Railsではfixtureを使うことでテストデータを作成することができる。
ユーザーのデータを登録するがパスワードはpassword_digestとしてハッシュ化されて保存されるので
fixtureのデータもハッシュ化したpasswordをpassword_digestに保存する必要がある
passwordのハッシュ化文字列を返すdigestメソッドを定義するが
fixture向けに使うため、ユーザーオブジェクトに対して使うことはない。
そのためクラスメソッドとして定義する。
また軽量化のためにテスト時はハッシュ化のコストを最小にし、本番環境ではセキュリティのためにコストを高めるようにする。
定義したメソッドがこちら
def User.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine::cost
BCrypt::Password.create(string, cost: cost)
end
digestメソッドの用意ができたのでフィクスチャを作成する。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
フィクスチャではerbが利用できるためpassword_digestにdigestメソッドを使って"password"をハッシュ化した文字列を代入している。
これでフィクスチャからユーザーを参照できるようになった。
さっそくテストを作成していく。
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
このテストでは
assert_redirected_to
でリダイレクト先が@user
になっているかチェックしている。
follow_redirect!
で実際にリダイレクト先に移動する。
あとはassert_template
でshowページ(ビュー)が表示されているか。
assert_select
でログインリンクがないこと。
ログアウトリンクがあること。showページリンクがあることをテストしている。
#####演習
1.post行をこのように修正する。
post login_path , params:{ session: { email: @user.email,password:"" }}
これでメールアドレスが有効でパスワードが無効というケースをテストできる。
2.user && user.authenticate(params[:session][:password])
↓
user&.authenticate(params[:session][:password])
この二つは等価になる
####ユーザー登録時にログイン
ユーザー登録をした後ログインしていないと紛らわしく、ユーザーの混乱を招くので登録と同時にログインするようにする。
ログイン処理はlog_in
メソッドで定義したためUsersControllerの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
ここで追加したユーザー作成時にログインしているかどうかを判定するために
ヘルパーメソッドに追加したlogged_in?
メソッドを使いたいが
テストではヘルパーメソッドを呼び出せないためtest_helper.rb
にテスト用ヘルパーメソッドとして
新規に登録する。
名前の取り違えを防ぐため、こちらはis_logged_in?
メソッドとして定義する。
def is_logged_in?
!session[:user_id].nil?
end
#####演習
1. is_logged_in?
メソッドでログインしているか確認しに行っているためREDになる。
2. コメントアウトは対象の行を選択してWindowsキー+/でできる。
###ログアウト
ログアウト機能を実装していく。
リンクはあるのでアクションを定義していく。
処理の内容はlog_in
メソッドの逆でセッションを削除すればいい。
def log_out
session.delete(:user_id)
@current_user = nil
end
定義したlog_out
メソッドを利用してdestroyアクションも作成する。
def destroy
log_out
redirect_to root_url
end
アクセスしたらログアウトしてルートURLに飛ぶ仕様。
ここでテストも更新する。
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "login with valid email/invalid information" do
get login_path
assert_template 'sessions/new'
post login_path , params:{ session: { email: @user.email,password:"" }}
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" 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)
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
ここではログアウト処理が行われているか、リダイレクトは正常に行われているか、表示されているリンクは正しいかなどを
テストしている。
またis_logged_in?
メソッドを活用して"login with valid email/invalid password"の中身を書き換えている。
####演習
1.セッションが削除され、ルートURLに飛ぶ。ページ内のリンクもログイン前のものに変わる。
動作確認は行っているので省略。
2.Chromeを使っていると消えないが、ログアウト処理は行われている。
debugger
などを使って直接確認してもわかる。
[12, 21] in /home/ubuntu/environment/sample_app/app/controllers/sessions_controller.rb
12: render 'new'
13: end
14: end
15:
16: def destroy
17: log_out
18: redirect_to root_url
19: debugger
=> 20: end
21: end
(byebug) logged_in?
false