目的
ユーザーがログインやログアウトを行えるようにする
8.1.1 Sessionsコントローラ
・ログインとログアウトの要素を、Sessionsコントローラの特定のRESTアクションにそれぞれ対応付ける
・ログインのフォームは、newアクションで処理
createアクションにPOSTリクエストを送信してログインする
destroyアクションにDELETEリクエストを送信してログアウトする
・routes.rb
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
8.1.2 ログインフォーム
フラッシュメッセージでエラーを表示する
・form_withヘルパー
セッションにはSessionモデルというものがない、そのため@sessionのようなインスタンス変数に相当するものもない
form_with(url: login_path, scope: :session)
リソースのスコープ(ここではセッション)とそれに対応するURLを具体的に引数に指定する
・ユーザー登録フォームの場合
<%= form_with(model: @user) do |f| %>
.
.
.
<% end %>
・ログインフォームのコードの場合
<% 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) 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 action="/login" accept-charset="UTF-8" method="post">
<input type="hidden" name="authenticity_token"
value="4d0...FFw" autocomplete="off"/>
<label for="session_email">Email</label>
<input class="form-control" type="email" name="session[email]"
id="session_email"/>
<label for="session_password">Password</label>
<input class="form-control" type="password" name="session[password]"
id="session_password"/>
<input type="submit" name="commit" value="Log in" class="btn btn-primary"
data-disable-with="Log in"/>
</form>
8.1.3 ユーザーの検索と認証
パスワードとメールアドレスの組み合わせが有効かどうかを判定する
Sessionsコントローラのcreateアクションを作成
class SessionsController < ApplicationController
def new
end
def create
render 'new', status: :unprocessable_entity
end
def destroy
end
end
createアクションの中では、ユーザーの認証に必要なあらゆる情報をparamsハッシュから簡単に取り出せる
#<ActionController::Parameters
{"authenticity_token"=>"…",
"session" =>#<ActionController::Parameters
{"email"=>"user@example.com",
"password"=>"foobar"} permitted: false>,
"commit"=>"Log in",
"controller"=>"sessions",
"action"=>"create"} permitted: false>
{ session: { password: "foobar", email: "user@example.com" } }のようになっており
params[:session][:email]と指定して値を取得できる
・ユーザーをデータベースから見つけて検証する
user = User.find_by(email: params[:session][:email].downcase)
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', status: :unprocessable_entity
end
end
def destroy
end
end
8.1.4 フラッシュメッセージを表示する
ユーザー登録の場合、エラーメッセージは特定のActive Recordオブジェクトに関連付けられていたのでその方法が使えた。
しかしセッションではActive Recordのモデルを使っていないため、
ログインに失敗したときには代わりにフラッシュメッセージを表示する
flash[:danger]で設定したメッセージは自動的に表示される
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', status: :unprocessable_entity
end
end
def destroy
end
end
8.1.5 フラッシュのテスト
前回のソースではエラーメッセージが残り続ける問題があったためそれを修正する
テストコードを作成する
- ログイン用のパスを開く
- 新しいセッションのフォームが正しく表示されたことを確認する
- わざと無効な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: "" } }
assert_response :unprocessable_entity
assert_template 'sessions/new'
assert_not flash.empty?
get root_path
assert flash.empty?
end
end
プロダクトコード
ログイン失敗時の正しい処理を実装
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'
render 'new', status: :unprocessable_entity
end
end
def destroy
end
end
8.2 ログイン
ログイン中の状態での有効な値の送信をフォームで正しく扱えるようにする
cookiesを使った一時セッションでユーザーをログインできるようにする
ApplicationコントローラにSessionヘルパーモジュールを読み込ませ、どのコントローラからでもログイン関連のメソッドを呼び出せるようにする
class ApplicationController < ActionController::Base
include SessionsHelper
end
8.2.1 log _inメソッド
・sessionメソッドを使って、単純なログインを行えるようにする
・ユーザーのブラウザ内の一時cookiesに暗号化済みのユーザーIDが自動で作成され、この後のページで、session[:user_id]を使ってユーザーIDを元通りに取り出すことができる。
cookiesメソッドと違い、
sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する
・log_inメソッド
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
・reset_session
セッション固定攻撃のリスクがあるため
ユーザーがログインする直前にセッションを必ず即座にリセットする
※セッション固定攻撃とは攻撃者が既に持っているセッションidをユーザーに使わせるように仕向けることで、攻撃者がユーザーとセッションを共有するというもの
sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
reset_session # ログインの直前に必ずこれを書くこと
log_in user
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new', status: :unprocessable_entity
end
end
def destroy
end
end
8.2.2 現在のユーザー
ユーザーIDを一時セッションの中に安全に置けるようになったので、
そのユーザーIDを別のページで取り出す
current_user
セッションIDに対応するユーザー名をデータベースから取り出せるようにする
<%= current_user.name %>
↓
redirect_to current_user
でユーザーのプロフィールページに簡単にリダイレクトできる
・現在のユーザーを検索する方法
User.find_by(id: session[:user_id])でユーザー情報を取得する
しかしIDが無効な場合(=ユーザーが存在しない場合)にもメソッドは例外を発生せず、nilを返す
・current_userを修正する
セッションにユーザーIDが存在しない場合、このコードは単に終了して自動的にnilを返す
sessions_helper.rb
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
・メモ化(memorization)
メソッド呼び出しの結果を変数に保存し、次回以降の呼び出しで再利用する
if @current_user.nil?
@current_user = User.find_by(id: session[:user_id])
else
@current_user
end
↓短縮系
@current_user ||= User.find_by(id: session[:user_id])
・「||=」(or equals)
@current_user ||= User.find_by(id: session[:user_id])
x = x ● yに該当し、●が||に置き換わっただけ
X = x + yと同じ
・最終系
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-else文を使用し、条件に応じて表示するリンクを使い分ける
<% if logged_in? %>
# ログインユーザー用のリンク
<% else %>
# ログインしていないユーザー用のリンク
<% end %>
・logged_in?メソッド
ユーザーがログインしていればtrue、その他ならfalseを返すメソッド
sessions_helper.rb
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
8.2.4 メニューのトグル機能
CSS idを利用してページ内にあるドロップダウンメニューを検索し、
メニューの表示と非表示をJavaScriptでトグルできるようにする
Importmap自身をインストールしてからTurboとStimulusをインストールすれば
RailsプロジェクトにJavaScriptを導入できる
ImportmapとTurboとStimulusをインストールする
rails importmap:install turbo:install stimulus:install
ドロップダウンメニュー実装手順
- CSSのactiveクラスの振る舞いを定義して、メニューが表示されるようにする
- JavaScriptを置くためのcustomディレクトリとmenu.jsファイルを新たに作成する
- menu.jsファイルにリスト 8.25のコードを書く
- Importmapを使って、customディレクトリが存在することをRailsに認識させる
- menu.jsをapplication.jsにインポートする
1
custom.scss
.
.
.
/* Dropdown menu */
.dropdown-menu.active {
display: block;
}
2
mkdir app/javascript/custom
$ touch app/javascript/custom/menu.js
3
menu.js
// メニュー操作
// トグルリスナーを追加してクリックをリッスンする
document.addEventListener("turbo:load", function() {
let account = document.querySelector("#account");
account.addEventListener("click", function(event) {
event.preventDefault();
let menu = document.querySelector("#dropdown-menu");
menu.classList.toggle("active");
});
});
4
importmap.rb
# Pin npm packages by running ./bin/importmap
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/custom", under: "custom"
5
application.js
// Configure your import map in config/importmap.rb.
// Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "custom/menu"
8.2.5 モバイル向けスタイリング
画面デザインをモバイルデバイス向けに調整する
・ビューポート用のmetaタグを追加する
ビューポートを使うと、開発者がPCモードとモバイルモードを切り替えられるようになる
<!DOCTYPE html>
<html>
<head>
<title><%= full_title(yield(:title)) %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
.
.
.
・モバイルモードでハンバーガーメニューを使う
_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<div class="navbar-header">
<button id="hamburger" type="button" class="navbar-toggle collapsed">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<ul id="navbar-menu"
class="nav navbar-nav navbar-right collapse navbar-collapse">
.
.
.
</ul>
</nav>
</div>
</header>
・モバイルメニューをトグルするJavaScriptコード
CSSの"active"クラスをトグルするのではなく、Bootstrapで定義されている"collapse"クラスをトグルする
menu.js
// トグルリスナーを追加してクリックをリッスンする
document.addEventListener("turbo:load", function() {
let hamburger = document.querySelector("#hamburger");
hamburger.addEventListener("click", function(event) {
event.preventDefault();
let menu = document.querySelector("#navbar-menu");
menu.classList.toggle("collapse");
});
8.2.6 レイアウトの変更をテストする
- ログイン用のパスを開く
- セッション用パスに有効な情報をPOSTする
- ログイン用リンクが表示されなくなったことを確認する
- ログアウト用リンクが表示されていることを確認する
- プロフィール用リンクが表示されていることを確認する
の順でテストを作成する
・テスト用データをfixture(フィクスチャ)で作成できる
このfixtureを使って、テストに必要なデータをtestデータベースに読み込んでおくことができる。
・BCrypt::Password.
as_secure_passwordでbcryptパスワードが作成されるので、同じ方法でfixture用のパスワードを作成する
stringはハッシュ化する文字列、costはコストパラメータと呼ばれる値
コストパラメータでは、ハッシュを算出するための計算コストを指定する
BCrypt::Password.create(string, cost: cost)
・fixture向けのdigestメソッドを追加する
user.rb
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 }
ERBコードでテストユーザー用の有効なパスワードを作成できる
<%= User.digest('password') %>
**ユーザーログインのテストで使うfixture**
users.yml
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
テストコード
テスト用のfixtureでは全員同じパスワード「password」を使う。
users_login_test.rb
require "test_helper"
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with valid information" do
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.7 ユーザー登録時にログインする
ユーザー登録中にログインも済ませる
・Usersコントローラのcreateアクションにlog_inを追加する
ログインの直前にreset_sessionメソッド呼び出しも追加する
users_controller.rb
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
reset_session
log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new', status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
・is_logged_in?
テストの為にユーザー登録の終わったユーザーがログイン状態になっているかどうかを確認できるようにするメソッドをhelperに追加する
test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
# 指定のワーカー数でテストを並列実行する
parallelize(workers: :number_of_processors)
# test/fixtures/*.ymlのfixtureをすべてセットアップする
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
end
8.3 ログアウト
ログアウト機能を追加する
・ログアウト時にreset_sessionメソッドですべてのセッション変数をリセットする
log_outメソッドにreset_sessionメソッドを実装
sessions_helper.rb
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
.
.
.
# 現在のユーザーをログアウトする
def log_out
reset_session
@current_user = nil # 安全のため
end
end
log_outメソッドは、Sessionsコントローラのdestroyアクションで使える
destroyアクションでユーザーのログアウト処理を実行する
リダイレクト時にstatus: :see_otherというHTTPステータスも指定している
sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
reset_session
log_in user
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new', status: :unprocessable_entity
end
end
def destroy
log_out
redirect_to root_url, status: :see_other
end
end
テストコード
テスト項目
・deleteメソッドでDELETEリクエストをログアウト用パスに発行
↓
・ユーザーがログアウトしたら、ステータスコード:see_otherによってルートURLにリダイレクトされることを確認
↓
ログイン用リンクが再度表示される
↓
ログアウト用リンクとプロフィール用リンクが非表示になることも確認
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_response :unprocessable_entity
assert_template 'sessions/new'
assert_not flash.empty?
get root_path
assert flash.empty?
end
test "login with valid information followed by logout" do
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_response :see_other
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
感想
今回はログインログアウト機能を実装しました
ログイン、ログアウトではsessionメソッドを使用してユーザーのログイン状態を変更する方法を学ぶことができました。
学習しているとrailsの用意されているメソッドで簡単に色々な実装ができることを実感しています!