静的型付け言語原理主義者によるRailsチュートリアル(第8章)
第8章 基本的なログイン機構
- ログインの基本的な仕組みを実装する
- ここでいうログインの基本的な仕組みとは、ブラウザがログインしている状態を保持し、ユーザーによってブラウザが閉じられた状態を破棄すると言った仕組みのこと
8.1 セッション
- HTTP はステートレスなプロトコルなため、ユーザーログインの必要な Web アプリケーションでは半永続的な接続であるセッションをコンピュータ間で設定する
- Rails でセッションを実装する最も一般的方法は、cookie を使用すること
session
という Rails メソッドで一時セッションが作成可能- 一時セッションはブラウザを閉じると自動的に終了する
- セッションを RESTful なリソースとしてモデリングできると、他の RESTful リソースと統一的に理解できて便利
- ログインページでは new で新規セッションを出力
- そのページでログインすると create でセッションを実際に作成して保存
- ログアウトすると destroy でセッションを破棄
8.1.1 Sessions コントローラ
- ログインとログアウトの要素を、Sessions コントローラの特定の REST アクションにぞれぞれ対応付ける
- ログインのフォームは
new
アクションで処理 create
アクションに POST リクエストを送信すると実際にログインするdestroy
アクションに DELETE リクエストを送信すると、ログアウトする
- ログインのフォームは
- まずは Sessions コントローラと
new
アクションを生成 - リソースを追加して標準的な RESTful アクションを get できるようにする
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
- Sessions コントローラのテストで名前付きルートを使うようにする
require 'test_helper'
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
get login_path
assert_response :success
end
end
rails routes
コマンドで全ルーティングが出力可能- このコマンドが業務で少しだけ触ったことがある
- 今なら Controller#Action の意味がよく分かる
- このコマンドが業務で少しだけ触ったことがある
8.1.2 ログインフォーム
- ログインフォームを整える
- ログインフォームとユーザー登録フォームは似ている
- ログインに失敗したときに表示するエラーメッセージはフラッシュメッセージで対応する
- Rails では下記コードだけで「フォームの
action
は /users という URL への POST である」を自動的に判定する
form_for(@user)
- しかしセッションの場合は Sessions モデルが存在しないので、リソースの名前とそれに対応するURLを具体的に指定する必要がある
form_for(:session, url: login_path)
- 適切な
form_for
を使うことで、ログインフォームを簡単に作成できる
<% 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>
8.1.3 ユーザーの検索と認証
- ログインでセッションを作成する場合に最初に行うのは、入力が無効な場合の処理
- 最小限の
create
アクションを Session コントローラで定義し、空のnew
アクションとdestroy
アクションもついでに作成しておく
class SessionsController < ApplicationController
def new
end
def create
render 'new'
end
def destroy
end
end
- /sessions/new フォームで送信する
params
ハッシュではsessions
キーの下にメールアドレスとパスワードがあるcreate
アクションの中ではユーザーの認証に必要なあらゆる情報をparams
ハッシュから取り出せる
- 以上を踏まえてユーザーをデータベースから見つけて検証する
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
- DBから取り出したメールアドレスを
downcase
で有効なメールアドレスに確実にマッチすうようにするのは Rails では定番の手法
8.1.4 フラッシュメッセージを表示する
- ログインに失敗したときにはフラッシュメッセージを表示する
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
- 上記コードにはフラッシュメッセージが一度表示されると残り続けるという問題がある
- Home ページに移動しても残っている
8.1.5 フラッシュのテスト
- フラッシュメッセージが消えないのはバグなのでまずはテストを書く
- ログイン用のパスを開く
- 新しいセッションのフォームが正しく表示されたことを確認する
- わざと無効な
params
ハッシュを使ってセッション用パスに POST する - 新しいセッションのフォームが再度送信され、フラッシュメッセージが追加されることを確認する
- 別のページにいったん移動する
- 移動先のページでフラッシュメッセージが表示されていないことを確認する
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
をflash.now
に変更すればレンダリングが終わっているページで特別にフラッシュメッセージを表示できるflash.now
のメッセージはその後のリクエストが発生したときに消滅する
8.2 ログイン
- 実際にログイン中の状態での有効な値の送信をフォームで正しく扱えるようにする
- cookie を使った一時セッションでユーザーをログインできるようにする
- ブラウザを閉じると自動的に有効期限が切れるもの
- Session コントローラを生成した時点ですでにセッション用ヘルパーモジュールも自動生成されている
- Rails のセッション用ヘルパーはビューにも自動的に読み込まれる
- Rails の全コントローラの親クラスである Application コントローラにこのモジュールを読み込ませれば、どのコントローラでも使えるようになる
- 安易な共有だ
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
include SessionsHelper
end
8.2.1 log_in メソッド
- Rails で事前定義済みの
session
メソッドを使って、単純なログインを行えるようにするsession
メソッドはハッシュのように扱える
session[:user_id] = user.id
- 上記コードを実行すると、ユーザーのブラウザ内の一時 cookies に暗号化済みのユーザー ID が自動で作成される
- どうやっているのか。。。
- この一時 cookie はブラウザを閉じた瞬間に有効期限が終了する
- 同じログイン手法を使い回せるように、Sessions ヘルパーに
log_in
という名前のメソッドを定義する
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
session
メソッドで作成した一時 cookie は自動的に暗号化される- 一時 cookie なので攻撃者に盗まれてもそれを使って本物のユーザーとしてログインすることは不可能
- ユーザーログインを行ってセッションの
create
アクションを完了し、ユーザーのプロフィールページにリダイレクトする
8.2.2 現在のユーザー
- 一時セッション内にあるユーザー ID を別のページで取り出す
current_user
メソッドでDBからユーザー名を取り出す
def current_user
if session[:user_id]
User.find_by(id: session[:user_id])
end
end
- セッションにユーザー ID が存在しない場合、上記コードは
nil
を返す- DBへの問い合わせ回数を減らす
User.find_by
の結果をインスタンス変数に保存- 1リクエスト内におけるデータベースへの問い合わせは最初の1回だけとなる
- ユーザーがログインしているかどうかに応じてアプリケーションの動作を変更するための準備が整った
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
8.2.3 レイアウトリンクを変更する
- ユーザーがログインしているといないときでレイアウトを変更してみる
- 考えられるのは、ERB コードの中で条件分岐すること
- お手軽だけど変更が入ると辛そう。。。
<% 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?
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>
- Bootstrap のドロップダウンメニュー機能を適用できる状態になったので、この機能を有効化するために Rails の
application.js
に Bootstrap の JavaScript ライブラリを追加する - ログインパスにアクセスして有効なユーザーとしてログインできるようになった
8.2.4 レイアウトの変更をテストする
- 以下の流れの統合テストを作成する
- ログイン用のパスを開く
- セッション用パスに有効な情報を post する
- ログイン用リンクが表示されなくなったことを確認する
- ログアウト用リンクが表示されていることを確認する
- プロフィール用リンクが表示されていることを確認する
- Rails ではテスト用データを fixture で作成できる
digest
メソッドを独自に定義し、password_digest
属性をユーザーの fixure に追加する
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: { 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
- 有効なユーザーを表す fixture を作成できるようになった
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
- fixture では ERB を利用できる
<%= User.digest('password') %>
- 上記コードでテストユーザー用の有効なパスワードを作成できる
- fixture ではハッシュ化されていない生のパスワードは参照できない
- テストで fixture のデータを参照するように変更する
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
8.2.5 ユーザー登録時にログイン
- ユーザー登録中にログインを済ませる
- Users コントローラの
create
アクションにlog_in
を追加するだけ
- Users コントローラの
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
- テストにおいてユーザーがログイン中かどうかをチェックするため、
is_logged_in?
メソッドを定義しておく- テストのセッションにユーザーがあれば
true
を返し、それ以外の場合はfalse
を返す
- テストのセッションにユーザーがあれば
- ヘルパーメソッドはテストから呼び出せないので、
current_user
は使えないsession
メソッドで代用する
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
8.3 ログアウト
- このアプリケーションではユーザーが明示的にログアウトするまではログイン状態を保てなくてはならない
- ログアウト用リンクは作成済みなのでユーザーセッションを破棄するための有効なアクションをコントローラで作成する
- RESTful ルールに従い、
destroy
アクションを作成する - ログアウト処理ではセッションからユーザー ID を削除する
delete
メソッドを実行するだけ- 現在のユーザーを
nil
に設定できる
session.delete(:user_id)
- 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
- ここで定義した
log_out
メソッドは、Session コントローラの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
- ログアウト機能をテストするために、ユーザーログインのテストを修正する
- ログイン後、
delete
メソッドで DELETE リクエストをログアウト用パスに発行し、ユーザーがログアウトしてルート URL にリダイレクトされたことを確認する
- ログイン後、
- 以上でサンプルアプリケーションの基本となるユーザー登録・ログイン・ログアウトの機能が完成
8.4 最後に
- サンプルアプリケーションのログイン機構を実装した
- 次はセッションより長くログイン情報を維持する
所感
ついにログインの機構を実装した。session や cookie の話も出てきていよいよ Web アプリケーションの開発をしている感が増してきた。フラッシュメッセージという概念は初耳だったのだが、Rails アプリでは一般的に使われるものなのだろうか?また、ERB の中での条件分岐も一般的なのだろうか?単純な if 文であれば問題ないかもしれないが、少しでも分岐するとカオスなロジックが生まれる土壌になるのではと危惧してしまった。