この章でやること
- ユーザーがログイン、ログアウトを行えるようにする
- 基本的な仕組みでログインを実装する
- ブラウザがログインしている状態を保持し、ユーザーによってブラウザが閉じられたら状態を破棄するといった認証システムを構築
- その後ログイン済みのユーザー(current user)だけがアクセスできるページや、扱える機能などを制御する
今回作成する認証システムと認可モデルは、今後実装する機能の基盤となる仕組みとなる
8.1 セッション
HTTPはステートレス(Stateless)なプロトコル。
文字通り「状態(state)」が「ない(less)」ので、HTTPのリクエスト1つ1つは、それより前のリクエストの情報をまったく利用できない、独立したトランザクションとして扱われる
つまり、ブラウザのあるページから別のページに移動したときに、ユーザーのIDを保持しておく手段がHTTPプロトコル内にはない。
そのため一般的に、ユーザーログインの必要なWebアプリケーションでは、セッション(Session)と呼ばれる半永続的な接続を設定する
セッションを実装する方法として最も一般的なのは、cookiesを使う方法で、cookiesとは、ユーザーのブラウザに保存される小さなテキストデータを指す。
cookiesは、あるページから別のページに移動した時にも破棄されないので、ここにユーザーIDなどの情報を保存して、Webアプリケーションはcookies内のデータを使って、ユーザーが所有する情報をデータベースから取り出すことができる
ここではsessionというRailsのメソッドを使って一時セッションを作成し、ブラウザを閉じると自動的に終了するようにする
(次の章ではもっと長続きするよう設定する)
ログインページではnewで新しいセッションを出力し、そのページでログインするとcreateでセッションを実際に作成して保存し、ログアウトするとdestroyでセッションを破棄する
トピックブランチで作業し後でマージする
$ git checkout -b basic-login
8.1.1 Sessionsコントローラ
まずは、Sessionsコントローラとnewアクションを生成し、こんな感じのルーティングを作る
| HTTPリクエスト | URL | 名前付きルート | アクション名 | 用途 |
|---|---|---|---|---|
| GET | /login | login_path | new | 新しいセッションのページ(ログイン) |
| POST | /login | login_path | create | 新しいセッションの作成(ログイン) |
| DELETE | /logout | logout_path | destroy | セッションの削除(ログアウト) |
$ rails generate controller Sessions new
rails generateでnewアクションを生成すると、それに対応するビューも生成される。
createやdestroyには対応するビューが必要ないので、無駄なビューを作成しないためにnewだけを指定
Usersリソースのときは専用のresourcesメソッドを使ってRESTfulなルーティングを自動的にフルセットで利用できるようにしたが
Sessionリソースではフルセットはいらないので、「名前付きルーティング」だけを使う。
この名前付きルーティングでは、
GETリクエストやPOSTリクエストをloginルーティングで、
DELETEリクエストをlogoutルーティングで扱う
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
自動で生成されたテストを名前付きルートに更新する
require 'test_helper'
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
get login_path
assert_response :success
end
end
rails routesコマンドを実行することで、いつでも現状のルーティングを確認することができる。
$ rails routes
Prefix Verb URI Pattern Controller#Action
root GET / static_pages#home
help GET /help(.:format) static_pages#help
about GET /about(.:format) static_pages#about
contact GET /contact(.:format) static_pages#contact
signup GET /signup(.:format) users#new
login GET /login(.:format) sessions#new
POST /login(.:format) sessions#create
logout DELETE /logout(.:format) sessions#destroy
users GET /users(.:format) users#index
POST /users(.:format) users#create
new_user GET /users/new(.:format) users#new
edit_user GET /users/:id/edit(.:format) users#edit
user GET /users/:id(.:format) users#show
PATCH /users/:id(.:format) users#update
PUT /users/:id(.:format) users#update
DELETE /users/:id(.:format) users#destroy
演習
GET login_pathとPOST login_pathとの違いを説明できますか? 少し考えてみましょう。
→getはビューを表示する(ブラウザがアプリに取得を要求)
→postはアプリのデータを更新する要求する(DBを変えるなど)
ターミナルのパイプ機能を使ってrails routesの実行結果とgrepコマンドを繋ぐことで、Usersリソースに関するルーティングだけを表示させることができます。同様にして、Sessionsリソースに関する結果だけを表示させてみましょう。現在、いくつのSessionsリソースがあるでしょうか?
$ 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
8.1.2 ログインフォーム
今度は新しいセッションで使うビュー、つまりログインフォームを整える
ログインフォームで入力した情報に誤りがあったときは、ログインページをもう一度表示してエラーメッセージを出力
前の章では、エラーメッセージの表示に専用のパーシャルを使い(_error_messages.html.erb)、そのパーシャルではActive Recordによって自動生成されるメッセージを使っていた。しかし、今回扱うセッションはActive Recordオブジェクトではないので、以前のようにActive Recordがよしなにエラーメッセージを表示してくれるということない。なのでフラッシュメッセージでエラーを表示していく
ユーザー登録フォームではform_withヘルパーを使い、ユーザーのインスタンス変数@userを引数にとった
<%= form_with(model: @user, local: true) do |f| %>
.
<% end %>
セッションフォームとユーザー登録フォームの最大の違いは、セッションにはSessionモデルというものがなく、そのため@userのようなインスタンス変数に相当するものもない。
したがって、新しいセッションフォームを作成するときにform_withヘルパーに渡さなければならない情報は、若干異なる
form_with(model: @user, local: true)
Railsでは上のように書くだけで、「フォームのactionは/usersというURLへのPOSTである」と自動的に判定しますが、セッションの場合はリソースのスコープ(ここではセッション)とそれに対応するURLを具体的に指定する必要がある
form_with(url: login_path, scope: :session, 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>
生成されるHTMLフォーム。
<form accept-charset="UTF-8" action="/login" method="post">
<input name="authenticity_token" type="hidden"
value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />
<label for="session_email">Email</label>
<input class="form-control" id="session_email"
name="session[email]" type="email" />
<label for="session_password">Password</label>
<input id="session_password" name="session[password]"
type="password" />
<input class="btn btn-primary" name="commit" type="submit"
value="Log in" />
</form>
フォーム送信後にparamsハッシュに入る値が、メールアドレスとパスワードのフィールドにそれぞれ対応したparams[:session][:email]とparams[:session][:password]になることが推測できる
name="session[email]"
name="session[password]"
↓↓
params[:session][:email]
params[:session][:password]
演習
リスト 8.4で定義したフォームで送信すると、Sessionsコントローラのcreateアクションに到達します。Railsはこれをどうやって実現しているでしょうか? 考えてみてください。ヒント:表 8.1とリスト 8.5の1行目に注目してください。
→formタグでmethod:postで送信している
8.1.3 ユーザーの検索と認証
ログインでセッションを作成する場合に最初に行うのは、入力が無効な場合の処理
①入力が無効な場合の処理を作成
②ログイン失敗時のエラーメッセージの配置
③ログイン成功時の土台部分の作成
まず、①を行う前に、Sessionsコントローラのcreateアクションにnewビューが出力されるよう定義
class SessionsController < ApplicationController
def new
end
def create
render 'new'
end
def destroy
end
end
デバック情報をみると以下の情報が表示
paramsハッシュでは、次のようにsessionキーの下にメールアドレスとパスワードがあります。
---
session:
email: 'user@example.com'
password: 'foobar'
commit: Log in
action: create
controller: sessions
ユーザー登録の時と同様、これらのパラメータはネストしたハッシュになっている。
{ session: { password: "foobar", email: "user@example.com" } }
つまり、これらのパラメータをpostリクエストでcreateアクションに送信すると、
createアクションにて、paramsハッシュで以下のようにデータを受け取る。
params[:session][:email]
params[:session][:password]
これを一つにすると
params[:session]
ちなみに、上記のemailとpasswordの部分は細かく見ると
{ session: { params: "foobar", email: "user@example.com"} }
このようになっている。
つまり、ハッシュの中にハッシュがあるhash to hashを採用している。
createアクションの中では、データをparamsハッシュから簡単に取り出せる。
実際に、createアクションでデータを受け取るには、
find_byメソッドと
authenticateメソッド
を使う。
authenticateメソッドは、認証に失敗するとfalseを返すので、その点を踏まえてコントローラに処理を記述。
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase) #送信されたメアドでユーザーを取り出す
if user && user.authenticate(params[:session][:password]) #もしuserが存在し、パスワードが一致した場合
# ユーザーログイン後にユーザー情報のページにリダイレクトする
else
# エラーメッセージを作成する
render 'new'
end
end
def destroy
end
end
user && user.authenticate(params[:session][:password])という文の
&& (論理積(and))は、取得したユーザーが有効かどうかを決定するために使う。
この場合は、入力されたメールアドレスを持つユーザーがデータベースに存在し、かつ入力されたパスワードがそのユーザーのパスワードである場合のみ、if文がtrueになる=言葉でまとめると「ユーザーがデータベースにあり、かつ、認証に成功した場合にのみ」
| User | Password | a && b |
|---|---|---|
| 存在しない | 何でもよい | (nil && [オブジェクト]) == false |
| 有効なユーザー | 誤ったパスワード | (true && false) == false |
| 有効なユーザー | 正しいパスワード | (true && true) == true |
演習
Railsコンソールを使って、表 8.2のそれぞれの式が合っているか確かめてみましょう. まずはuser = nilの場合を、次にuser = User.firstとした場合を確かめてみてください。ヒント: 必ず論理値オブジェクトとなるように、4.2.2で紹介した!!のテクニックを使ってみましょう。例: !!(user && user.authenticate('foobar'))
>> user = nil
=> nil
>> !!(user && user.authenticate('foobar')) #nilとfoobarパスワード正
=> false
>> user = User.first
>> !!(user && user.authenticate('foobaz')) #user.firstとfoobazパスワード誤
=> false
>> !!(user && user.authenticate('foobar')) #user.firstとfoobarパスワード正
=> true
8.1.4 フラッシュメッセージを表示する
前章のエラーメッセージはUSERモデルが自動で生成してくれた
しかしセッションではActive Recordのモデルを使っていないため、使えない
そこで、ログインに失敗したときにはフラッシュメッセージを表示するようにする
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
上のコードには誤りがある。実は上のコードのままでは、リクエストのフラッシュメッセージが一度表示されると消えずに残ってしまう
表示したテンプレートをrenderメソッドで強制的に再レンダリングしてもリクエストと見なされないため、リクエストのメッセージが消えない。
8.1.5 フラッシュのテスト
この小さなバグ(フラッシュメッセージが消えない問題)は、「エラーをキャッチするテストを先に書いて、そのエラーが解決するようにコードを書く」に該当する状況
なので、ログインフォームの送信について簡単な統合テストを作成する
$ rails generate integration_test users_login
invoke test_unit
create test/integration/users_login_test.rb
以下の流れでテストを書く
- ログイン用のパスを開く
- 新しいセッションのフォームが正しく表示されたことを確認する
- わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
- 新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認する
- 別のページ(Homeページなど) にいったん移動する
- 移動先のページでフラッシュメッセージが表示されていないことを確認する
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: "" } } #わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
assert_template 'sessions/new' #新しいセッションのフォームが再度表示され、
assert_not flash.empty? #フラッシュメッセージが追加されることを確認する flashが空でないか
get root_path #Homeページ にいったん移動する
assert flash.empty? #homeページでフラッシュメッセージが表示されていないことを確認する
end
end
今時点testはred (移動してもフラッシュが消えないバグが解決してないため)
$ rails test test/integration/users_login_test.rb
上の例のように、rails testの引数にテストファイルを与えると、そのテストファイルだけを実行することができる。
テストをパスさせるには、flashをflash.nowに置き換えることで解決する
flash.nowは、レンダリングが終わっているページで特別にフラッシュメッセージを表示することができる
flash.nowのメッセージはその後リクエストが発生したときに消滅する
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.now[:danger] = 'Invalid email/password combination' #flash.nowに変更
render 'new'
end
end
def destroy
end
end
$ rails test test/integration/users_login_test.rb
$ rails test
演習
8.1.4の処理の流れが正しく動いているかどうか、ブラウザで確認してみてください。特に、flashがうまく機能しているかどうか、フラッシュメッセージの表示後に違うページに移動することを忘れないでください。
→/loginから適当に入力し、動作確認済
8.2 ログイン
cookiesを使って一時セッションでユーザーをログインできるようにし、ログイン状態を保持したままデータを送信できるようにする。
今回はブラウザを閉じるとcookiesの有効期限が自動的に切れるようにするが、後にブラウザを閉じても保持されるセッションを追加する。
以前はセッション実装する際、様々なコントローラやビューで多くの数のメソッドを定義する必要があったが
現在はRailsのモジュール機能を使うだけでそうしたメソッドを一箇所にパッケージ化できる
Module Helper#メソッドの集合(ヘルパー)を定義
Sessionsコントローラ生成時には既にセッション用のヘルパーモジュールも自動生成されていて、Railsのセッション用ヘルパーはビューにも自動的に読み込まれる。
つまり、Railsの全コントローラの親クラスであるApplicationコントローラにこのモジュール(ヘルパー)を読み込ませることで、どのコントローラでも使えるようになる。
class ApplicationController < ActionController::Base
include SessionsHelper
end
8.2.1 log_inメソッド
定義済みのsessionメソッドを使って、単純なログインを行えるようにする。
(sessionメソッドはSessionsコントローラとは無関係なので注意。)
sessionメソッドはハッシュのように扱える。
session[:user_id] = user.id
上記のコードでは、ユーザーのブラウザ内の一時cookiesに暗号化済みのユーザーidが自動生成される。
この後のページで、session[:user_id]を使って、ユーザーIDを元通りに取り出すことができる。
後に詳解するcookiesメソッドとは対照的に、sessionメソッドで作られた一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。
同じログイン手法を様々な場所で使い回せるようにする為に、Sessionsヘルパーにlog_inという名前のメソッドを定義しておく。
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user) #user引数を定義
session[:user_id] = user.id #sessionメソッドで[:user_id]にuser引数のidを代入
end
end
sessionメソッドで作成した一時cookiesは自動的に暗号化され、上記のコードは保護される。
sessionメソッドについて参考
https://qiita.com/zettaittenani/items/a75f0da8f44cfe0f85c0
ここが重要だが、攻撃者がたとえこの情報をcookiesから盗みだすことができたとしても、それを使って本物のユーザーとしてログインすることはできない。
ただし、それはsessionメソッドで作成した「一時セッション」にしか該当せず、cookiesメソッドで作成した永続的セッションでは断言できない。
(ブラウザ閉じて消えるsessionメソッドなら大丈夫だが、ブラウザ閉じても消えないcookiesメソッドだと危ないってこと)
何故なら、cookiesの場合セッションハイジャックという攻撃を受ける可能性があるから。
これは後に詳解する。
log_inというヘルパーメソッドができたので、ユーザーログインを行って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])
log_in user
# sessions_helperのlog_inメソッドを実行し、sessionメソッドのuser_id(ブラウザに一時cookiesとして保存)にidを送る
redirect_to user # ログインしたユーザーのページにリダイレクト
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
end
end
今の状態でもログインできるが、ログインしたかどうかのメッセージが表示されないので、このままだとユーザーからしたら本当にログインできたのかがわからない。(ブラウザセッションを直接確認すればわかるが)
なので、後にセッションに含まれるIDを利用して、データベースから現在のユーザー名を取り出して、画面で表示する。
さらに、アプリケーションのレイアウト上のリンクに、現在ログインしているユーザー(自分)のプロフィールを表示できるようにもする。
演習
有効なユーザーで実際にログインし、ブラウザからcookiesの情報を調べてみてください。このとき、sessionの値はどうなっているでしょうか? ヒント: ブラウザでcookiesを調べる方法が分からない? 今こそググってみるときです!(コラム 1.2)
先ほどの演習課題と同様に、Expiresの値について調べてみてください。
→開発者ツール(画面でF12)→アプリケーション→Cookieで見つけれる
sessionの値は大量の文字列
8.2.2 現在のユーザー
ユーザーIDを一時セッションの中に安全に置けるようになったので、今度はそのユーザーIDを別のページで取り出す
そのためには、current_userメソッドを定義して、セッションIDに対応するユーザー名をデータベースから取り出せるようにするcurrent_userメソッドの目的は、次のようなコードを書けるようにすること
①<%= current_user.name %>
また、このようなコードで、ユーザーのプロフィールページにリダイレクトできるようにもする
②redirect_to current_user
この時、ユーザーを検索する方法としては
User.find(session[:user_id])
が思いつくが、ユーザーIDが存在しない状態でfindを使うとエラーが発生してしまう。
たとえばユーザーがログインしていない場合、sessionハッシュにidがない(NULL)ので、
結果的にidが存在しないユーザーを探してしまうのでエラーになってしまう。
この状態を修正すべく、createメソッド内でメールアドレスの検索に使ったのと同じfind_byメソッドを使う。
User.find_by(id: session[:user_id])
この方法だと、IDが無効(ユーザーが存在しない)の場合でもメソッドはエラーを発生せずにnilを返してくれる。
(Usersテーブルから目的のidの値を持ったユーザーを探している為)
findとfind_byの違いが分からなかったので、調べました。
https://qiita.com/tsuchinoko_run/items/f3926caaec461cfa1ca3
idの値が分かっていて、そのidのデータを取得したい場合・・・find
idの値が不明で、id以外のカラムを検索条件としたい場合・・・find_by
ということのようです。
find_byメソッドの手法を使って、current_userを次のように定義
def current_user
if session[:user_id] #もしsession[:user_id]=ログインしてたら
User.find_by(id: session[:user_id]) #同じidのUserを探す
end
end
セッションにユーザーIDが存在しない場合、nilを返す。(falseではない)
存在しない場合でも何回もDBへ問い合わせしていないので早い
いる場合は、ログインユーザーのidとDBのidが同じユーザーを返している。
さらに、Rubyの慣習に従って、User.find_byの実行結果をインスタンス変数に代入する。
こうすることで、1リクエスト内におけるDBへの問い合わせは最初の一回だけになり、以後の呼び出しではインスタンス変数の結果を再利用するだけになる。
これが、Webサービスを高速化させる重要なテクニック。
if @current_user.nil? #もし@current_user.nil? なら
@current_user = User.find_by(id: session[:user_id]) #@userにセッションと同じuserを探して代入
else
@current_user #いなければ、?
end
既に@current_user(ログインユーザー)がいればユーザーを表示し、
いなければユーザーを@current_userに代入できる。
上のコードは *or演算子「||」*を使えれば、たった1行で書ける
@current_user = @current_user || User.find_by(id: session[:user_id])
Userオブジェクトそのものの論理値は常にtrueな為、@current_userに何も代入されていない時だけ、find_byが読み出される。つまり、DBへの無駄な読み出しが行われなくなる。
Rubyではさらに短縮形で書く。
@current_user ||= User.find_by(id: session[:user_id])
||=は、手前の@current_userがあれば@current_userに代入、なければUser.find〜を代入、という意味となる。
この概念をor equalsと呼ぶ。
コラム 8.1. 「||=」とは何か?
この「||=」(or equals)という代入演算子はRubyで広く使われているイディオムであり、Ruby開発者を志すならこの演算子に習熟することが重要
多くのコンピュータプログラムでは、次のような記法で変数の値を1つ増やすことができる
x = x + 1
そして、Ruby(およびC、C++、Perl、Python、Javaなどの多くのプログラミング言語)では、上の演算を次のような短縮形で表記することもできる
x += 1
他の演算子についても同様の短縮形が利用可能
$ rails console
>> x = 1
=> 1
>> x += 1
=> 2
>> x *= 3
=> 6
>> x -= 8
=> -2
>> x /= 2
=> -1
いずれの場合も、●という演算子があるときの「x = x ● y」と「x ●= y」の動作は同じ
Rubyでは、「変数の値がnilなら変数に代入するが、nilでなければ代入しない(変数の値を変えない)」という操作が非常によく使われ,
or演算子 || を使えば、次のように書ける
>> @foo
=> nil
>> @foo = @foo || "bar" #nilの論理値はfalseになるので、@fooへの最初の代入「nil || "bar"」の評価値は"bar"に
=> "bar"
>> @foo = @foo || "baz" #既に@fooがあるので、評価値は"bar"に
=> "bar"
nilの論理値はfalseになるので、@fooへの最初の代入「nil || "bar"」の評価値は"bar"になる。
同様に、2つ目の代入「@foo || "baz"」("bar" || "baz"など)の評価値は"bar"に
Rubyでは、nilとfalseを除いて、あらゆるオブジェクトの論理値がtrueになるように設計されている
さらにRubyでは、||演算子をいくつも連続して式の中で使う場合、項を左から順に評価し、最初にtrueになった時点で処理を終えるように設計されているので、こうなる
なお、||式を左から右に評価し、演算子の左の値が最初にtrueになった時点で処理を終了するという評価法を短絡評価(short-circuit evaluation)と呼ぶ。
上記の演算子をコンソールセッション上で実際に実行して比較してみると、@foo = @foo || "bar"はx = x O yに該当し、Oが||に置き換わっただけである
x = x + 1 -> x += 1
x = x * 3 -> x *= 3
x = x - 8 -> x -= 8
x = x / 2 -> x /= 2
@foo = @foo || "bar" -> @foo ||= "bar"
これで「@foo = @foo || "bar"」は「@foo ||= "bar"」と等価であることがわかる
この記法をcurrent_userの文脈で使うと次のような簡潔なコードになる
@current_user ||= User.find_by(id: session[:user_id])
上記での記法を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])
# @current_userがいれば@current_userに代入し、いなければsessionのユーザーを探す
end
end
end
演習
Railsコンソールを使って、User.find_by(id: ...)で対応するユーザーが検索に引っかからなかったとき、nilを返すことを確認してみましょう。
irb(main):002:0> User.find_by(id: "4")
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
=> nil
userがいない場合User.find_by(id: "4")はnilを返す
先ほどと同様に、今度は:user_idキーを持つsessionハッシュを作成してみましょう。リスト 8.17に記したステップに従って、||=演算子がうまく動くことも確認してみましょう。
リスト 8.17: sessionのシミュレーション
>> session = {}
>> session[:user_id] = nil
>> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>→ユーザがいないのでnilが返る
>> session[:user_id]= User.first.id
>> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?> →ユーザーがいるので、一番目(first)のユーザーが@current_userに入る
>> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?> →上と同じ
irb(main):003:0> session = {}
=> {}
irb(main):004:0> session[:user_id] = nil
=> nil
irb(main):005:0> @current_user ||= User.find_by(id: session[:user_id])
User Load (7.7ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]]
=> nil
irb(main):006:0> session[:user_id]= User.first.id
User Load (0.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> 1
irb(main):007:0> @current_user ||= User.find_by(id: session[:user_id])
User Load (0.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", crea...
irb(main):008:0> @current_user ||= User.find_by(id: session[:user_id])
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", crea...
8.2.3 レイアウトリンクを変更する
ユーザーがログインしているときとそうでないときでレイアウトを変更する
この時点でメニューに対する統合テストを書くのがいいが、ここでもいくつか新しい概念を覚える必要があるので、テストの作成は後回しする
さて、レイアウトのリンクを変更する方法として考えられるのは、ERBコードの中でif-else文を使用し、条件に応じて表示するリンクを使い分けること
<% if logged_in? %> もしログインしてれば
# ログインユーザー用のリンク
<% else %> ログインしてなければ
# ログインしていないユーザー用のリンク
<% end %>
このコードを書くためには、論理値を返すlogged_in?メソッドが必要なので、まずはそれを定義する。
ユーザーがログイン中の状態とは「sessionにユーザーidが存在している」こと、つまりcurrent_userがnilではないという状態を指す。
これをチェックするには否定演算子が必要なので、! を使う
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? #上で定義したcurrent_userメソッドを否定演算子にし、trueかfalseを返すようにする
end
end
理論値を返すメソッドの準備ができたので、リンク新しく作るリンクは4つですが、そのうち次の2つのリンクにを整える
今回は未定義のリンク
<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>
ログアウト用リンク
<%= link_to "Log out", logout_path, method: :delete %>
引数としてハッシュ(method:)を渡している。このハッシュでは、HTTPのDELETEリクエストを使うように指示
プロフィール用リンクについても同様に変更
<%= link_to "Profile", current_user %>
なお、上のコードは省略系↓↓のようにも書ける
<%= link_to "Profile", user_path(current_user) %>
しかしこの状況ではcurrent_userを使う方が、Railsによってuser_path(current_user)に変換され、プロフィールへのリンクが自動的に生成できるので便利
次に、ユーザーがログインしていない場合は、ログイン用パスを使って、リンクを作成
<%= link_to "Log in", login_path %>
ここまでの結果をヘッダーのパーシャル部分に適用
<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> #userページへlink
<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>
dropdown-toggle"などのbootstrapによるCSSのドロップダウン機能を有効にするため、Railsのapplication.jsファイルを通して、Bootstrapに同梱されているJavaScriptライブラリの他にjQueryも読み込む必要がある
以前、gemfileでbootstrap-sass gemでインストールしたが、一部は含まれていなかったため。
RailsのアセットパイプラインはWebpackとYarnのどちらともうまく動きます。そして上述のJavaScriptをインクルードするには、WebpackとYarnの両方が必要。
なのでまずは、①jQueryとBootstrapのJavaScriptライブラリを、アプリケーションにインストール
$ yarn add jquery@3.4.1 bootstrap@3.4.1
②アプリケーションでjQueryを有効にするには、Webpackの環境ファイルを編集して、以下の内容を追加する必要がある。
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する
require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")
require("jquery")
import "bootstrap"
これでようやくログイン機能が動くようになる
演習
ブラウザのcookieインスペクタ機能を使って(8.2.1.1)、セッション用のcookieを削除してみてください。ヘッダー部分にあるリンクは非ログイン状態のものになっているでしょうか? 確認してみましょう。
→見た目の確認
もう一度ログインしてみて、ヘッダーのレイアウトが変わったことを確認してみましょう。
→見た目の確認
その後、ブラウザを再起動させ、再び非ログイン状態に戻ったことも確認してみてください。注意: もしブラウザの[閉じたときの状態に戻す]機能をオンにしていると、セッション情報も復元される可能性があります。もしその機能をオンにしている場合、忘れずにオフにしておきましょう→見た目の確認
8.2.4 レイアウトの変更をテストする
統合テストを書いて演習で確認した動きをテストで表現し、今後の回帰バグの発生をキャッチできるようする
手順は以下の通り
- ログイン用のパスを開く
- セッション用パスに有効な情報をpostする
- ログイン用リンクが表示されなくなったことを確認する
- ログアウト用リンクが表示されていることを確認する
- プロフィール用リンクが表示されていることを確認する
上の変更を確認するためには、テスト時に登録済みユーザーとしてログインしておく必要がある
当然ながら、データベースにそのためのユーザーが登録されていなければならないが、Railsでは、このようなテスト用データをfixture(フィクスチャ)で作成できる。
このfixtureを使って、テストに必要なデータをtestデータベースに読み込んでおくことができる
現時点のテストでは、ユーザーは1人でOK。
fixtureには有効な名前とメールアドレスを持ったユーザーを一人用意する。
さらに、Sessionsコントローラのcreateアクションに送信されたパスワードと比較できるよう、
password_digest属性も追加する。
その為に、digestメソッドを独自に定義する。
has_secure_passwordを使うとbcryptパスワードが作成されるので、
bcryptパスワードハッシュ化→fixture用のパスワード にする。
なお、Railsでは次のコードでパスワードをハッシュ化している。
BCrypt::Password.create(string, cost: cost)
string=ハッシュ化する文字列
cost=コストパラメータ
costパラメータはハッシュを算出する為の計算コストを指定する。
コストパラメータ値が高いほど復号化が困難になる為、本番環境ではコストパラメータを設定する。
なお、テスト中は計算コストを下げて軽くしたい。
それには、secure_passwordのソースコードが参考になる。
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
少々込み入っているが、コストパラメータをテスト中は最小にし、本番環境ではしっかりと計算する方法がわかれば十分
上記のdigestメソッドは今後活用するので、Userモデル(User.rb)に置いておく。
この計算はユーザーごとに行う必要はなく、インスタンスメソッドで定義する必要はない。
そのため、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: true
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
digestメソッドができたので、有効なユーザーを表すfixtureを作成できるようになった
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %> #User.digest #user.rbで定義したメソッド
fixtureではERBを利用して、先ほど定義したdigestメソッドを使用している。
(password)をstringで文字列に変換してハッシュ化している
fixtureではハッシュ化されていない生のパスワードは参照できない。
理由として、password属性が存在しない点が挙げられる。
(has_secure_passwordの機能を使って、passwordとpasswordconfirmationの値をデータベースにpassword_digestの値として追加していたから)
つまり、passwordという文字列をテスト用のfixtureのpassword_digestに与えることで、
ハッシュ化前のパスワード(password)を読み出すことができる。
(password_digestの平文がpassword)
有効なユーザーをfixtureで作成出来たので、テストでfixtureのデータを参照する。
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael) #fixtureで定義したmichealを利用
end
test "login with valid information" do
get login_path #loginする
post login_path, params: { session: { email: @user.email, #createアクションにfixtureで定義したemailとpasswordをparamsへ送信
password: 'password' } }
assert_redirected_to @user #@userページへリダイレクトしたか確認
follow_redirect! #リダイレクト
assert_template 'users/show' #showページが表示されているか
assert_select "a[href=?]", login_path, count: 0 #ログインパスがないか
assert_select "a[href=?]", logout_path
assert_select "a[href=?]", user_path(@user)
end
end
テストはグリーンになるはず
演習
1リスト 8.15の8行目にあるif userから下をすべてコメントアウトすると、ユーザー名とパスワードを入力して認証しなくてもテストが通ってしまうことを確認しましょう(リスト 8.26)。通ってしまう理由は、リスト 8.9では「メールアドレスは正しいがパスワードが誤っている」ケースをテストしていないからです。このテストがないのは重大な手抜かりですので、テストスイートで正しいメールアドレスをUsersのログインテストに追加して、この手抜かりを修正してください(リスト 8.27)。テストが red (失敗)することを確認し、それから先ほどの8行目以降のコメントアウトを元に戻すと green (パス)することを確認してください(この演習の修正は重要なので、この先の 8.3のメインのコードにも修正を反映してあります)。
test "login with valid email/invalid password" do
get login_path
assert_template 'sessions/new'
post login_path, params: { session: { email: @user.email, #Fill_inにこれを入れたらtestはredになった
password: "invalid" } }
assert_template 'sessions/new'
assert_not flash.empty?
get root_path
assert flash.empty?
end
2.“safe navigation演算子”(または“ぼっち演算子)と呼ばれる&.を用いて、リスト8.15の8行目の論理値(boolean値)のテストを、リスト 8.2812 のようにシンプルに変えてください。Rubyのぼっち演算子を使うと、obj && obj.methodのようなパターンをobj&.methodのように凝縮した形で書けます。変更後も、リスト 8.27のテストがパスすることを確認してください。
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user&.authenticate(params[:session][:password]) #user.を&.に書き換える
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
問題なくテストはパスする
8.2.5 ユーザー登録時にログイン
今のままでは、新規登録の終わったユーザーがデフォルトでログインしていないのでユーザー登録後にはログインしている状態にする
そのためにはUsersコントローラのcreateアクションにlog_inを追加するだけでOK
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 #save後にlog_inメソッドを発動
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の動作をテストするために、logged_in?ヘルパーメソッドとは別に、is_logged_in?ヘルパーメソッドを定義しておくと便利
このヘルパーメソッドは、テストのセッションにユーザーがあればtrueを返し、それ以外の場合はfalseを返すメソッドにする
残念ながらヘルパーメソッドはテストから呼び出せないため、current_userを呼び出せません。sessionメソッドはテストでも利用できるので、これを代わりに使う。
※helperファイルで定義したmethodはapp側でしか使えない。test_helperはtest側でしか使えない
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil? #sessionがあればtrue
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? #signup後loginしてるか
end
end
これで、テストすればgreen
演習
リスト 8.29のlog_inの行をコメントアウトすると、テストスイートは red になるでしょうか? それとも green になるでしょうか? 確認してみましょう。
→redになる log_inされなくなるのでred
現在使っているテキストエディタの機能を使って、リスト 8.29をまとめてコメントアウトできないか調べてみましょう。また、コメントアウトの前後でテストスイートを実行し、コメントアウトすると red に、コメントアウトを元に戻すと green になることを確認してみましょう。ヒント: コメントアウト後にファイルを保存することを忘れないようにしましょう。また、テキストエディタのコメントアウト機能については『開発基礎編: テキストエディタ』の 「コメントアウト機能」などを参照してみてください。
→VScodeでは、「command+a」でALL選択 「command+/」でコメントアウトできる
8.3 ログアウト
ログアウト機能を追加する
ログアウトは簡単で、deleteメソッドを使えば可能
session.delete(:user_id)
上のコードで、現在のユーザーをnilに設定できる。
次に、log_inおよび関連メソッドのときと同様に、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
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 #destroyメソッドを定義
log_out #helperで定義したlog_outメソッドを使う
redirect_to root_url
end
end
ログアウトメソッドをテストで使う
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
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
end
test_helperでis_logged_in?ヘルパーメソッドを定義し、利用できるようにしたおかげで、
is_logged_in?を書くだけで、sessionが空ならfalse(これをassert_notで空ならtrueにした)というテストを実現できた。
これでtestはパスする
演習
ブラウザから[Log out]リンクをクリックし、どんな変化が起こるか確認してみましょう。また、リスト 8.35で定義した3つのステップを実行してみて、うまく動いているかどうか確認してみましょう。
→確認
cookiesの内容を調べてみて、ログアウト後にはsessionが正常に削除されていることを確認してみましょう。
→確認
gitに追加する
$ rails test
$ git add -A
$ git commit -m "Implement basic login"
$ git checkout master
$ git merge basic-login
マージ後、リモートのリポジトリにpush
$ rails test
$ git push
最後に、いつものようにHerokuにデプロイ
$ git push heroku
以上
2/22追記
gitのマスターへマージ後、rails testでエラーというかテストが実施されないエラーが発生。
そのためherokuへアップデートもできない。
エラー内容(テストができない)
/Users/kiyomasa/environment/sample_app/app/controllers/application_controller.rb:2:in
`<class:ApplicationController>': uninitialized constant ApplicationController::SessionsHelper (NameError)
調べてみると
https://teratail.com/questions/228180
この方、もしくは
https://stackoverflow.com/questions/17984330/why-do-i-get-uninitialized-constant-applicationcontrollersessionshelper-name/17984474
この方と同じ状態になりました。
class ApplicationController < ActionController::Base
include SessionsHelper #スペルミスは確認
end
module SessionsHelper #moduleのスペルも間違ってなさそう
# 渡されたユーザーでログインする
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
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
時間を置いて、VSCODEを再起動したらテストができるようになりました。... 原因不明でした。
もしそうなってしまった方は試してみるといいかもしれません。