セッション
HTTPはステートレス(状態を保持できない)なプロトコルです。
HTTPのリクエスト1つ1つは、それより前のリクエスト情報を利用することができません。
そのため、ブラウザのあるページから別のページに移動した時に、ユーザーのIDを保持しておく手段として、Webアプリケーションでは、セッションという半永続的な接続をコンピュータ間(ユーザーのパソコンのWebブラウザとrailsサーバなど)に別途設定します。
実装方法
Rails でセッションを実装する方法として最も一般的なのは、cookiesを使う方法です。
cookiesとは、ユーザーのブラウザに保存される小さなテキストデータです。
cookiesは、あるページから別のページに移動した時にもされないので、ここにユーザーID などの情報を保存できます。
アプリケーションはcookies内のデータを使って、例えばログイン中のユーザーが所有する情報をデータベースから取り出すことができます。
Rails では、sessionメソッドを使い、一時セッションを作成します。この一時セッションは、ブラウザを閉じると自動的に終了します。
また、第9章では、もっと長続きするセッションの作り方を学びます。
HTTPリクエスト | URL | 名前付きルート | アクション名 | 用途 |
---|---|---|---|---|
GET | /login | login_path | new | 新しいセッションのページ(ログイン) |
POST | /login | login_path | create | 新しいセッションの作成(ログイン) |
DELETE | /logout | logout_path | destroy | セッションの削除(ログアウト) |
ログインフォーム
ユーザー登録フォームでform_forヘルパーを使ったときは、ユーザーのインスタンス変数@userを引数に取っていました。
<%= form_for(@user) do |f| %>
.
.
.
<% end %>
上記のように書くだけで、「フォームのactionは/usersというURLへのPOSTリクエストである」と解釈してくれていました。
しかし、セッションにはSessionモデルというものがないので、@userのようなインスタンス変数に相当するものがありません。
そのため、ログインフォームでは下記のようにリソースの名前とそれに対応するURLを具体的に指定する必要がある。
form_for(:session, url: login_path)
ログインフォーム
<% 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>
上記で生成したログインフォームのHTML
<form accept-charset="UTF-8" action="/login" method="post">
<input name="utf8" type="hidden" value="✓" />
<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="text" />
<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]になることが推測できます。
ユーザーの検索と認証
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
上記では、送信されたメールアドレスを使い、データベースからユーザーを取り出しています。(ユーザー登録時にメールアドレスは全て小文字で登録しているので、確実にマッチさせるためにdowncaseメソッドを使っている)
find_byメソッドを使用しているので、ユーザー情報が存在する場合はユーザーオブジェクトが、存在しない場合はnilが返される。
Rubyではnilとfalse以外の全てのオブジェクトはtrueになる性質を考慮すると、&&の前後の組み合わせは下記の通りとなります。
user && user.authenticate(params[:session][:password])
User | Password | a && b |
---|---|---|
存在しない | 何でも良い | (nil && [オブジェクト]) == false |
有効なユーザー | 誤ったパスワード | (true && false) == false |
有効なユーザー | 正しいパスワード | (true && true) == true |
ユーザーが存在しない場合にはnilが返され、falseになるため、その時点でこのif文はfalseとなり、右の式は実行されないため、エラーにはならない。
これがもし左の式が存在しなかったら、エラーになってします。
フラッシュ
flashとflash.nowの違い
flash
次のアクションまで表示され、その次のアクションでは消える。
通常flashはリダイレクトを伴って利用するので、登録後にリダイレクトされた画面でメッセージが表示されており、次のアクションが実行された時点で消える。
flash.now
次のアクションで消えます。
登録に失敗してrenderでテンプレートを表示した場合、次のアクションでは消えて欲しいので、flash.nowを使います。
ログイン
同じログイン手法を様々な場所で使い回せるようにするために、Sessionヘルパーにlog_inというメソッドを定義する。
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
end
sessionメソッドで作成した一時cookieは自動的に暗号化されて保護される。
sessionメソッドで作成されあた一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了します。
log_inというメソッドを使い、ユーザのログインを行う。
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
end
end
上記では、リダイレクトを使っていますが
redirect_to user
これは、Rails では自動的に「user_url(user)」を変換しています。
現在のユーザー
現在のログイン中のユーザー情報を返すメソッドを定義します。
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
ユーザーIDが存在しない状態でfindメソッドを使うと例外が発生してしまうので、この場合はユーザーIDが存在しなかったら、nilを返してくれるfind_byメソッドを使います。
@current_user = @current_user || User.find_by(id: session[:user_id])
@current_user ||= User.find_by(id: session[:user_id]) ⇦上記の短縮形
上記の意味は、@current_userに何も代入されていないときだけfind_byメソッドを呼び出しが実行されているので、既に@current_userにユーザ情報が代入されている場合はデータベースへの無駄な問い合わせが行われない。
テスト
fixture
テスト用のデータを作成できる。
テストユーザー用意
fixture向けのdigestメソッドを追加する
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') %>
有効な情報を使ってユーザーログインテスト
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
@user = users(:michael)
usersはfixtureのファイル名users.ymlを表し、:michaelというシンボルはユーザーを参照するためのキーを表します。
assert_redirected_to user_url(@user.id)
assert_redirected_to @user ⇦短縮形
上記はリダイレクト先が正しいかをチェックしている。
follow redirect!
指定のページに実際に移動する。
assert_select "a[href=?]", login_path, count: 0
count: 0というオプションをassert_selectに追加すると、渡したパターンに一致するリンクが0件かどうかを確認している。
ログアウト
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
.
.
.
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end