#第8章
この章では、ログインやログアウト機能を追加する。また、認証システムや認可モデルも導入する。
##セッション
HTTPはステートレス(状態がない)プロトコルのため、以前の情報を利用できない。つまり、別のページに遷移する時に情報を保持することが出来ない。
そのため、セッション(Session)を使うことで、状態を保持することができる。
Railsでセッションを実装する方法として、cookiesを使うのが一般的。
Q:cookiesとは?
A:ブラウザに保存される小さなテキストデータのこと。そのため、別ページに遷移しても情報が保持される。
セッションを利用するには、こんな下記のように
・ログインページで、新しいセッションを出力し、ログインするとセッションを実際に作成し保存。
・ログアウトするとセッションを破棄する。
こんな感じでやる。
###Sessionsコントローラ
セッションも、Sessionsコントローラを作成して、特定のRESTアクションにそれぞれ結びつけることにする。
Sessionsコントローラー作成
$ rails generate controller Sessions new
なお、newアクションを生成すると、それに対応したビューも同時に作られるのは知っての通りだが、無駄なビューを作成しないため、newだけ指定する。
resources
メソッドを使ってフルセットを利用しないため、名前付きルーティングだけを使う。
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
次にテストを更新する。
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
とコマンド実行すると現状のルーティングを確認できる。
演習
-
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)
ログインフォームのコード
<% 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コントローラ(暫定版)
class SessionsController < ApplicationController
def new
end
def create
render 'new'
end
def destroy
end
end
フォームを送信したら、render 'new'でnewページが描画される。
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
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
メソッドで強制的に再レンダリングしているため、リクエストとならない。従って、メッセージが常に表示されたままとなる。
###フラッシュのテスト
フラッシュ問題があったので、ログイン関係の簡単な統合テストを作成する。
いつもの通りTDDで行う。
$ rails generate integration_test users_login
テストコードの手順
①ログイン用のパスを開く
②新しいセッションのフォームが正しく表示されているか
③わざと無効なparams
ハッシュを使ってセッション用パスにPOST
④新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されているか
⑤別のページ(Homeページなど)にいったん移動
⑥移動先のページでフラッシュにメッセージが表示されてないことを確認
この内容をテストコードに落とし込む
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
この問題を解決するには、flash
をflash.now
に置き換えればOK
flash.now
はリクエストが発生した時に消える。
なので、redirect_to
で使うとフラッシュが表示されないので、render
と合わせて覚えるのがよさげかな?
flash.now[:danger] = 'Invalid email/password combination'
テストしたらOKだった
演習
エラーメッセージが表示されたので別のページに遷移すると、、、
エラーメッセージが消えたのでOK
##ログイン
cookiesを使った一時セッションでユーザーをログインできるようにする。このcookiesはブラウザを閉じると自動的に有効期限が切れるものだが、第9章ではブラウザを閉じても保持されるセッションを追加する。
Railsの全コントローラーの親クラスであるApplicationコントローラーにSessionsHelper
を読み込ませれば、どのコントローラーでも使えるようになる。
class ApplicationController < ActionController::Base
include SessionsHelper
end
###log_in
メソッド
session
メソッドを使って、単純なログインを行えるようにする。
session
メソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。(cookies
メソッドは、対照的に永続性がある)
ログインは様々な場所で使うので、Sessionsヘルパーにlog_in
という名前のメソッドを定義する。
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
session
メソッドで作成した一時cookiesは自動的に暗号化され、保護される。
また、攻撃者がこのcookies情報を盗み出せたとしても、本物のユーザーとしてログインできない。しかし、これは一時セッションのみしか該当せず、永続的セッションでは断言できない。
log_in
というヘルパーメソッドを定義したので、セッションのcreate
アクションを完了させて、プロフィールページにリダイレクトさせる。
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
メソッドに適用させると以下のコードになる。
# 現在ログイン中のユーザーを返す(いる場合)
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_user
がnil
でない状態のこと。
チェックには、否定演算子の!
を使用する。これで論理値の逆転が起こる。
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
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>
<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の環境ファイルを編集、追加する。
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
する。
require("jquery")
import "bootstrap"
これでログイン中とログアウトで別々のヘッダーが表示されるようになった。
###レイアウトの変更をテストする
先ほどレイアウトの変更を行ったので、今回はその変更に対して統合テストをする。
手順
①ログイン用のパスを開く
②セッション用パスに有効な情報を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ファイルがわざわざユーザーオブジェクトにアクセスする必要性はないので、クラスメソッドで定義したほうが良い。
# 渡された文字列のハッシュ値を返す
def User.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
fixtureファイルにテストユーザーを追記
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
は先ほど作ったユーザーにアクセスするためのキー。
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
を返す。
しかし、ヘルパーメソッドはテストから呼び出せないため、テストヘルパーにコードを書く。
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
end
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
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
ヘルパーモジュールにlog_out
メソッドを入れた。
次に、log_out
メソッドを呼び出すdestroy
アクションを追加する。
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にあげて終了