8.1セッション
Railsではsessionというメソッドを使って一時セッションを作成します。この一時セッションはブラウザを閉じると自動的に終了します。その後の章では、Railsのcookiesメソッドを使用して、ブラウザを閉じてもセッションを継続する方法について学びます。
cookieを使ってユーザー情報の保存や取り出しをおこなっていくってことですかね?
8.1セッション
ログインとログアウトの要素を、Sessionsコントローラの特定のRESTアクションにそれぞれ対応付ける
ログインのフォームは、newアクションで処理
なぜnewアクションなのかとGPTに尋ねてみた時に
新しいリソース(通常はデータベース内の新しいレコード)を作成するためのフォームを表示するために使用される
とのことでした。納得。
#リソースを追加して標準的な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
今回の追加分が
get "/login", to: "sessions#new"
post "/login", to: "sessions#create"
delete "/logout", to: "sessions#destroy"
この3行でした。 なんでgetやpostが各アクションの関連性なんだ?って疑問に思いGPTで調べました。
get "/login", to: "sessions#new"
"/login"へのGETリクエストが"sessions#new"アクションにルーティングされる設定です。この設定は一般的に、ログインフォームを表示するために使用されます。
具体的には、"/login"へのGETリクエストが送信されると、"sessions"コントローラーの"new"アクションが呼び出されます。このアクションは通常、ログインフォームを表示するためのビューをレンダリングするためのコードを含みます。
post "/login", to: "sessions#create"
ログインは新しいセッションを作成する操作であり、そのセッションはデータベースに新規作成されることが一般的です。このような場合には、一般的にPOSTメソッドが使用されます。そのため、ログイン操作は"create"アクションにマッピングされます。
delete "/logout", to: "sessions#destroy"
ログアウトは既存のセッションを破棄する操作であり、セッションの削除が行われます。これはデータベース上の既存のデータを削除する操作に相当します。このような場合にはDELETEメソッドが使用されるのが適切です。そのため、ログアウト操作は"destroy"アクションにマッピングされます。
表示→new
セッション作成→create
ログアウト(セッション削除)→delite
という解釈みたいです。
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
get login_path
assert_response :success
end
end
test helperでsessionしているときのlogin_pathを設定
$ rails routes
で今までrouteに追加した内容を確認
$ 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
たくさん追加してきたんですね。
何をどうしたいときはこのrouteリストを元にGPTに確認をとるのが良さそうです。
8.1.2ログインフォーム
続いて新しいセッションで使うビュー(ログインフォーム)を整える
@sessionというインスタント変数がない為
form_withヘルパーに情報を7章と異なる。
form_with(model: @user)
と書くだけでフォームのactionは/usersというURLへのPOSTである」と自動的に判定しますが、セッションの場合はリソースのスコープ(ここではセッション)とそれに対応するURLを具体的に指定する必要があるとのこと。
よくわからないので次を見ていきます。
form_with(url: login_path, scope: :session)
これを設定するだけで動くようになるらしい。
補足
Railsのフォームヘルパーであるform_withを使用する場合、通常はリソースベースのルーティング(RESTfulなルーティング)を利用してフォームのアクションURLを自動的に生成します。しかし、セッションの場合は、リソースベースのルーティングが適用されないため、手動でアクションURLを指定する必要があります。
login_pathは、セッションの新規作成(ログイン)アクションに対応するURLを示します。これは、RailsのルーティングシステムによってPOST /loginにマッピングされるはずです。
scope: :sessionは、このフォームがセッションと関連していることを示します。これにより、セッション用のルーティングを正しく解決できるようになります。
したがって、form_with(url: login_path, scope: :session)を使用することで、セッションのフォームに正しいアクションURLが指定され、適切にルーティングされます。
ルーティングする為にこの記述を書くって解釈ですかね?
<% 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>
このフォームがform_withに送られると記述されてます。
8.1.3ユーザーの検索と認証
今度は入力が向こうの場合の処理を記述します。
class SessionsController < ApplicationController
def new
end
def create
render 'new', status: :unprocessable_entity
end
def destroy
end
end
createアクションをSessionsコントローラで定義、空のnewアクションと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])
# ユーザーログイン後にユーザー情報のページにリダイレクトする
else
# エラーメッセージを作成する
render 'new', status: :unprocessable_entity
end
end
def destroy
end
end
8.2ログイン
Applicationコントローラに自動生成されたセッション用のヘルパーを読み込ませ、
どのコントローラからでもログイン関連のメソッドを呼び出す。
#ApplicationコントローラにSessionヘルパーモジュールを読み込む
class ApplicationController < ActionController::Base
include SessionsHelper
end
sisssion helperでloginメソッドを使いまわせるようにする。
8.2.1log_inメソッド
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
sessionメソッドで作成した一時cookiesは自動的に暗号化される
→攻撃者がたとえこの情報をcookiesから盗み出すことに成功しても、それを使って本物のユーザーとしてログインすることはできません
セッション固定攻撃への対策は、ユーザーがログインする直前にセッションを必ず即座にリセットすること。
Railsに組み込まれているreset_session
メソッドを使用する。
8.2.2現在のユーザー
current_userメソッドを定義して、セッションIDに対応するユーザー名をデータベースから取り出せるようにする。
ちなみに筆者はこのcurrent_userメソッドが起因のエラー原因でめちゃくちゃ転けました。
何度かリセットしたりしています。
個人的に注視したいセクションです。
current_userメソッドの目的は下記のコードを書けるようにするのが目的
<%= current_user.name %>
次のコマンドでredirect_to current_user
簡単にリダイレクトすることも可能
現在のuserを検索する方法はUser.find(session[:user_id])
を使用するfindメソッドを使うこと。
(以下引用)
しかし6.1.4で既に経験済みのとおり、ユーザーIDが存在しない状態でfindを使うと例外が発生してしまいます。findのこの動作は、プロフィールページでは適切でした。IDが無効の場合は例外を発生してくれなければ困るからです。しかし、「ユーザーがログインしていない」などの状況が考えられる今回のケースでは、session[:user_id]の値がnilになる可能性があります。この状態を修正するために、createメソッド内でメールアドレスの検索に使ったのと同じfind_byメソッドを使うことにします。ただし今度はemailではなく、idで検索します。
ここ個人的にすごく大事だと思います。
例外を発生せず見つからない場合はnilを返すようにします。
def current_user
if session[:user_id]
User.find_by(id: session[:user_id])
end
end
User.find_byの実行結果をインスタンス変数に保存する工夫
if @current_user.nil?
@current_user = User.find_by(id: session[:user_id])
else
@current_user
end
このコードが
current_user = @current_user || User.find_by(id: session[:user_id]
or演算子「||」を使えれば、メモ化(memoization)として省略でき1行で書けるとのこと。
どういうことなの?というわけで説明を読み解いていきます。
(引用)
ここで重要なのは、Userオブジェクトそのものの論理値は常にtrueになることです。そのおかげで、@current_userに何も代入されていないときだけfind_by呼び出しが実行され、無駄なデータベースへの読み出しが行われなくなります。
ごめんなさい、意味がわかりません...。
(以下GPTの答え)
このコードは、@current_user変数がまだ設定されていない場合にのみ、データベースからユーザーを検索して@current_userに代入する「メモ化」の手法を実装しています。
@current_userが既に設定されている場合、右側のUser.find_by(id: session[:user_id])は評価されず、そのまま@current_userの値が使われます。
@current_userがまだ設定されていない場合、右側のUser.find_by(id: session[:user_id])が評価され、その結果が@current_userに代入されます。
||演算子は、「左側の値がfalseまたはnilであれば、右側の値を返す」という動作をします。したがって、@current_userがまだ設定されていない場合にはUser.find_by(id: session[:user_id])が実行され、それ以外の場合には@current_userの値がそのまま使われます。
これにより、@current_userがまだ設定されていない場合にのみデータベースへのアクセスが行われ、不要なアクセスが削減されます。
ちょっと意味がわかってきました。
current_user = @current_user || User.find_by(id: session[:user_id]
つまりこのコードはデフォルトでtrueになっている。 つまりsession idとか諸々ある状態
こいつがない場合は右側が実行されてnilやfalse等が返却されるってことですかね。
難しい!!
で更にRubyだと省略できるようです。
@current_user ||= User.find_by(id: session[:user_id])
めちゃくちゃ短いですね。
#セッションに含まれる現在のユーザーを検索する
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
というわけで組み込みます。@current_user演算子〜〜のコードがあることにより
elseの部分が略されてる感じですね。
8.2.3レイアウトリンクを変更する
Accountのリンクを追加する作業。
ここはなぜか原因不明の表示されなかった部分で、気づいたら表示していたという謎な箇所でした。
レイアウトのリンクを変更する方法としてERBコードの中でif-else文を使用し、条件に応じて表示するリンクを使い分ける。
この辺はHTMLの感覚からすると条件分岐するのか、すごって驚いてます。
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
リンクの定義としてcurrent_userを使う。
<%= link_to "Profile", 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="#" id="account" class="dropdown-toggle">
Account <b class="caret"></b>
</a>
<ul id="dropdown-menu" 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,
data: { "turbo-method": :delete } %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Log in", login_path %></li>
<% end %>
</ul>
</nav>
</div>
</header>
ログイン済のユーザーが表示される。
メニューのトグル機能
javascriptをRailsに入れるセクションです。
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js
<!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 %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= render 'layouts/header' %>
<div class="container">
<% flash.each do |message_type, message| %>
<div class="alert alert-<%= message_type %>"><%= message %></div>
<% end %>
<%= yield %>
<%= render 'layouts/footer' %>
<%= debug(params) if Rails.env.development? %>
</div>
</body>
</html>
<%= javascript_importmap_tags %>
を挿入します。
ここではAccoutのボタンを押すとドロップダウンメニューが表示するという機能の為に
jsを導入するようです。
(以下戦略を引用)
1.CSSのactiveクラスの振る舞いを定義して、メニューが表示されるようにする
2.JavaScriptを置くためのcustomディレクトリとmenu.jsファイルを新たに作成する
3.menu.jsファイルにリスト 8.24のコードを書く
4.Importmapを使って、customディレクトリが存在することをRailsに認識させる
5.menu.jsをapplication.jsにインポートする
だそうです。
#STEP1 ドロップダウンメニューを有効にする
.
.
.
/* Dropdown menu */
.dropdown-menu.active {
display: block;
}
#STEP2 ドロップダウンメニューで使うカスタムJavaScriptファイルを追加する
$ mkdir app/javascript/custom
$ touch app/javascript/custom/menu.js
app/javascriptディレクトリの下にcustomディレクトリを作成し、そこにmenu.jsファイルを作成
// メニュー操作
// トグルリスナーを追加してクリックをリッスンする
document.addEventListener("turbo:load", function() {
let account = document.querySelector("#account");
if (account) {
account.addEventListener("click", function(event) {
event.preventDefault();
let menu = document.querySelector("#dropdown-menu");
menu.classList.toggle("active");
});
}
});
ちょっとこのコードの意味わからなかったのでGPTの解説
turbo:loadイベントの検知: Turbo Driveがページをロードすると発火するイベントにリスナーを設定します。
クリックイベントの設定: IDが「account」の要素がクリックされた際に、そのイベントのデフォルト動作をキャンセルし、ドロップダウンメニューの表示を切り替える動作を追加します。
ドロップダウンメニューの切り替え: 「dropdown-menu」のIDを持つ要素に対して「active」クラスを切り替えることで、メニューの表示状態をトグルします。
つまり快適にする為にロードをして、通常のAccout状態の通常挙動をキャンセルし
ドロップダウンメニューを出るようにするって感じですかね。
#STEP4.customディレクトリにあるJavaScriptコードをImportmapの設定に追加した様子
# 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"
importmapを使ってプロジェクトのカスタムJavaScriptコードがapp/javascript/customにあることをRailsに認識させる設定
#STEP5 ドロップダウンメニューのJavaScriptをインポートする
// 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"
最後のステップ5では、ドロップダウンメニューのJavaScriptコードをインポートする行を追加
なんでcustom/menuをimportがRailsにjavascriptを認識させることなのか疑問に思いましたので
GPTにこれも聞いてみた。
import "custom/menu" というコードを app/javascript/application.js に追加する目的は、Rails アプリケーションで custom/menu.js というカスタムJavaScriptファイルを使用できるようにするためです。このコード行が重要な理由は以下の通りです:
・モジュールのインポート: import ステートメントはES6 (ECMAScript 2015) の機能で、別のJavaScriptファイルから特定の機能やモジュールを現在のファイルに取り込むために使用されます。これにより、ファイル間でコードを共有し、再利用することが容易になります。
・コードの分離と管理: このインポートによって、menu.js に記述された機能(この場合はドロップダウンメニューの動作制御)が application.js からアクセス可能になり、Webアプリケーション全体で統一的に機能を使用できるようになります。これは特に大規模なアプリケーションで有効で、コードの整理と管理を助けます。
**・アプリケーションの読み込み最適化:**import ステートメントは必要なコードだけを読み込むため、アプリケーションの起動時間とパフォーマンスが向上します。未使用のコードを省略することができるため、ページのロードが早くなります。
これらの理由から、custom/menu を application.js にインポートすることは、ドロップダウンメニュー機能の適切な統合と効率的な利用を保証します。
なるほどトグルのmenu.jsを利用する為にimportするわけですね。
8.2.7ユーザー登録時にログインする
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
ユーザー登録中にログインさせる。
ここでポイントなのはsession_idをリセットしセッション攻撃を防ぐ。
そしてログインをさせる。
つづいてlogout
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
.
.
.
# 現在のユーザーをログアウトする
def log_out
reset_session
@current_user = nil # 安全のため
end
end
#セッションを破棄する
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
ログアウトしたら破棄される模様
本章まとめ(引用)
・Railsのsessionメソッドを使うと、あるページから別のページに移動するときの状態を保持できる。一時的な状態の保存にはcookiesも使える
・ログインフォームでは、ユーザーがログインするための新しいセッションが作成できる
・flash.nowメソッドを使うと、描画済みのページにもフラッシュメッセージを表示できる
・テスト駆動開発は、回帰バグを防ぐときに便利
・sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できる
・ログインの状態に応じて、ページ内で表示するリンクを切り替えることができる
・統合テストでは、ルーティング、データベースの更新、レイアウトの変更が正しく行われているかを確認できる
本章まとめ(引用)
今回はsessionという概念が非常に重要だと感じた。
ソフトウェアを作成するということはセキュリティ対策に十分注意が必要で
仕様条件に十分に考慮しなくてはいけないと感じた。
特に特定ページの表示権限やパスワードのハッシュ化などの設定は非常に重要だと感じました。
またjavascriptの組み合わせ方等も学んだので色々応用がききそうですね。
こうやってプログラミングが少しずつ書けるようになったらいいと思います。