0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

rails-tutorial第9章

Last updated at Posted at 2020-06-06

###8章補足。
sessionを変数と考えていたが、正確には
sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できるということである。

#発展的なログイン機構

sessionだけを使ったログインだとサーバーやブラウザを閉じてしまうと、またログインが必要となる。そこを改善できないものだろうか。

ベターなのは、ユーザーの意思で、期限のあるセッションか、永続的なセッションかを選べるようにするといい。

###cookiesとsessionの違い。

###超わかりやすい説明。

cookiesは診察券、session idは整理番号と考えるとわかりやすい。
cookiesはクライアント側に保存されるので、その情報から、以前何を買ったとかショッピングカートに入れたとかがわかる。

session idもcookieに保存されるのだが、session idはブラウザとサーバーの通信状態を呼ぶから、
別のページに移動したり、別のデバイスから入ろうとすると、その時点でsession idはsessionというハッシュから削除されてしまう。

なので、たとえcookieからsessionidを盗み出せたとしても、別のページに移動したり、別のデバイスから入ろうとすると、その時点でsession idはsessionというハッシュから削除されてしまうという理由から、そんなsession idはそもそもハッシュに保存されていませんよーとなってしまう。

ただ、cookieは診察券みたいなものなので、「あ、前回はこの病気を見てもらったんですねー」というようなことができる。

以上!!!!!!!!!!!!!!!!!!!!!!

sessionはサーバとブラウザが相互にやり取りをして、どちらかが切れたら終了というものだった。

###cookieの実装方法

cookieはクライアント側に記憶トークン(rememberトークンともいう)を付与し、それをパスワードダイジェストのように、ハッシュ化したものをDBに保存する。

そのため、まずは記憶トークンをハッシュ化したものを保存する場所をDBに作っていこう。

$ rails generate migration add_remember_digest_to_users remember_digest:string

ここでも、rails g migration add_追加するカラム名_to_テーブル名 カラム名:データ型
という風に指定してあげると、rails側で以下のようなファイルを勝手に作ってくれる。

db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :remember_digest, :string
  end
end

これを確認したら rails db:migrate

###記憶トークンに使われるランダムな文字列をどうやって作るか?

Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドなら、この用途にぴったり合いそうです3。このメソッドは、A–Z、a–z、0–9、"-"、"_"のいずれかの文字 (64種類) からなる長さ22のランダムな文字列を返します (64種類なのでbase64と呼ばれています)。典型的なbase64の文字列は、次のようなものです。

$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"

SecureRandomクラスのクラスメソッドってことだよね。

この文字列をクライアント側に送って、さらに、この文字列をハッシュ化したものをDBに保存する。

###トークン生成用のメソッドの定義

app/models/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: { 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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end
end

ランダムなトークンを作るためだけに、インスタンスを生成するのは勿体無い。
なので、user.rbにクラスメソッドとして定義すれば良い。

ただ、今の状態だと、記憶トークンdigestを参照することができるが、記憶トークンの平文を参照することができない。
記憶トークンの平文は、password_digest実装時のpasswordやpassword_confirmationのような仮想的な属性である。

これを実装するにはどうすればいいだろうか?

実は上記のような仮想的な属性はゲッター、セッターの実装ができる。一時的に保存できるがDBに保存はされない。

これは、

attr_accessor :remember_token

とすることで、自動的にゲッターとセッターを実装してくれる。
言い換えると、attr_accessorはメソッドを定義するメソッドと言える。

###attr_accessor :remember_tokenを実装

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end

次は上記の最後に定義されているrememberメソッドを見ていこう

rememberメソッドはユーザーがチェックボックスにチェックを入れてログインをした。
その時に呼び出されるメソッドである。

ここで注意点
rememberはインスタンスメソッド。これが呼び出されてる時は必ず呼び出し元がいるということ。

で、self.remember_tokenのselfには rememberメソッドの呼び出し元が代入される。

###selfの省略について

update_attributeはself.update_attributeの省略形である。
しかし、一つ前のself.remember_tokenのselfは省略してはいけない。

どのようなルールがあるのだろうか?

省略してはいけないのは、

def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

1文目は、selfを省略してしまうと、remember_tokenというローカル変数にUser.new_tokenを代入するという意味になってしまう。

つまり、代入文であり、代入文の左辺だった時はselfが必要。

update_attributeはメソッド(インスタンスメソッド)であることが明白なので、selfを省略してもOK

次は、

###cookieからsessionの状態を復元する機能を実装しよう。

ログインした時にはユーザー自身が入力したemailからユーザーインスタンスをfindし、入力したパスワードを元に@user.authenticateをして認証することができた。

しかし、cookieの場合は、emailが存在しないので、どうやってユーザーインスタンスを引っ張ってくるかが課題になる。

これを解決するために、署名付きユーザーidというものを使う。

これは、cookieを送る時に、@user.idを暗号化したものを一緒に送る。

これを、元の@user.idに復号化してあげて、

そこから得たuser.idを使って、find_byしてインスタンスを引っ張ってくる。

で、authenticateメソッドはパスワードを比較するためのメソッドなので、記憶トークンには使えない。
なので、自分で定義する必要がある。

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

rememberメソッドをsession_controllerのcreateアクションに実装する。

app/controllers/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])
      log_in user
      remember 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

これは、ユーザーがメールとパスワードを入れてログインしたら、記憶トークンをハッシュ化したものをDBに保存するよーって処理。

ただ、ここでremember userというように引数を取っていることにお気づきだろうか?

実はこのメソッドはsession_helperに定義された別のメソッドだったのだ!!!

実はこのremember(user)メソッドで記憶トークンをクライアント側に送るなどの処理もしている。

このメソッドを定義していこう

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # ユーザーのセッションを永続的にする
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  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

sessionメソッドと同様、:user_idをキーにして、user.idを代入できる。
この場合、ユーザーIDが生のテキストとしてcookieに保存される。

署名付きcookieを使うためには、cookies.signedメソッドを使用する。
cookieをブラウザに保存する前に暗号化を行う。

###じゃあ、結局どうやってsession状態を復元するんだよ。

current_userメソッドを変える。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # ユーザーのセッションを永続的にする
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  end

  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

簡単にいうと、
sessionでログインできればそれでログインし、
できなければ、cookieを使ってログインしてというメソッド。

if (user_id = session[:user_id])は
user_idにsession[:user_id]を代入した結果、値が存在すればという条件式になる。

elsif (user_id = cookies.signed[:user_id])
また、このコードのsignedは暗号化された文字列を復号化する役割がある。
つまり、signedは暗号化もできるし、復号化することもできる。

ちなみに
if user && user.authenticated?(cookies[:remember_token])

のcookies[:remember_token]は、クライアント側に保存されているもの。

つまり、DBに保存されているユーザーインスタンスの記憶トークンのハッシュ化された値と、クライアント側に保存されている記憶トークンをハッシュ化したものを比較してくれている。

もし、if文もelsif文も失敗したら、nilが返ってくるという仕様。
そのため、logged_in?メソッドをそのまま使える。

この時点でテストは失敗している。

###ユーザーを忘れる。

これはrememberメソッド、remember(user)メソッドの全く逆のことを実装すれば良い

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
end

これはrememberメソッドと対になるメソッド。
DBの値をnilにしたので、
次は焼いたクッキーを消すメソッドを実装しよう。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 永続的セッションを破棄する
  def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

  # 現在のユーザーをログアウトする
  def log_out
    forget(current_user)
    session.delete(:user_id)
    @current_user = nil
  end
end

forget(user)はremember(user)の対になるメソッド。
また、log_outメソッドに forget(current_user)を追加しないと、cookieを使ってまたログインできてしまう。

さっきテストで失敗してしまったのは、ログアウトしたらログアウトパスが本来0個のはずなのに、cookieをつかったログインが成功してしまい、1つ発見されてしまったからだろう。

実はこれだけじゃあ実装は終わらないぜ

###目立たないバグ潰し

二つのログイン済みのタブがあり、どちらか一方をログアウトさせ、もう片方もログアウトさせようとするとエラーが起こる。
これは1回目でcurrent_userがなくなり、2回目で、nilにforgetメソッドを呼び出そうとしているため、NoMethodErrorが起こってしまうからだ。

これを解決するには、log_outメソッドを使えるのはlog_inしている時のみという条件をつける。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

これで1つ目のバグは解決。

2つ目のバグを解決しよう!!!

###cookieの暴走問題

1つはSafari、もう1つはChromeでログインする。
そうするとどちらにもcookieが付与された状態になる。

これで、Chromeのタブでログアウトを実行。

さらに、Safariのタブを消してしまう。

それで、Safariでもう一度アプリのページを開こうとするとエラーになってしまう。

これは、Chromeのログアウトの時点でDBのremember_digestはnilになっている。
そしてSafariのcookie情報でcurrent_userを見つけようとするもDBの値がnilになっているので見つけられず、例外を出してエラーを出すようになってしまうかららしい。bcryptによるもの。

じゃあ、どうやって解決する?

まずは回帰バグを防ぐためにテストコードを書こう。

test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end

最後のテストは、authenticated?()メソッドの引数にnilや空文字を入れたらfalseを返すでしょ?というテストである。ちなみにcookieの暴走バグはfalseすら返さず例外を出しているために起きている。

テスト結果は以下。

ERROR["test_authenticated?_should_return_false_for_a_user_with_nil_digest", UserTest, 0.47229603000005227]
 test_authenticated?_should_return_false_for_a_user_with_nil_digest#UserTest (0.47s)
BCrypt::Errors::InvalidHash:         BCrypt::Errors::InvalidHash: invalid hash
            app/models/user.rb:32:in `new'
            app/models/user.rb:32:in `authenticated?'
            test/models/user_test.rb:70:in `block in <class:UserTest>'

  21/21: [===========================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.57426s
21 tests, 50 assertions, 0 failures, 1 errors, 0 skips

ちなみに、failuresは期待された値にならなかった時。
errorsは期待された値とか関係なく、例外などが出た時に表示される。

###cookieの暴走バグ解決法

これの解決方法は、remember_digestがnilの時はbcryptを実行せずに、falseを返してあげれば良い

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
end

これで、cookieの暴走バグを解決できる。
returnを実行されると、それがメソッドの戻り値になるので、以降のメソッド処理は実行されなくなる。

###チェックボックスの実装

まずはチェックボックスをログインフォームに実装しよう

app/views/sessions/new.html.erb
<% 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.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

チェックボックスを実装すると、
params[:session][:remember_me]

の値が、チェックされてる時は'1'
チェックされてない時は'0'となる。

これを利用していこう。

if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end

このように条件分岐していけばいいのだが、
これを三項演算子を使うとスマートに実装することができる。

app/controllers/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])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

基本的にチェックボックスにチェックをつけないとcookieは焼かれないが、一応万全を期すためにforget()メソッドを呼び出している。

###Remember meのテストを書こう。

まずテスト環境でログインしたユーザーを作るためにtest_helperにメソッドを定義していく

test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end

  # テストユーザーとしてログインする
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

class ActionDispatch::IntegrationTest

  # テストユーザーとしてログインする
  def log_in_as(user, password: 'password', remember_me: '1')
    post login_path, params: { session: { email: user.email,
                                          password: password,
                                          remember_me: remember_me } }
  end
end

下のクラス定義されてるやつはなんぞや??

統合テストは基本的にブラウザでできることができるようなテスト、

だからこのようにいちいち情報を入力してログインしてもらう必要がある。
そのためメソッドを分けて定義している

つまり、上のlog_in_asはケーステスト用のメソッド。
下のlog_in_asは統合テスト用のメソッドとなっている。

ちなみに password: 'password'と remember_me: '1'はデフォルト値として設定されている。

###チェックボックスの統合テストをしよう

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_empty cookies['remember_token']
  end

  test "login without remembering" do
    # クッキーを保存してログイン
    log_in_as(@user, remember_me: '1')
    delete logout_path
    # クッキーを削除してログイン
    log_in_as(@user, remember_me: '0')
    assert_empty cookies['remember_token']
  end
end

ここではテスト通る。

###raiseを理解する。

raiseはテストの途中で例外を発生させる機能。

app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      raise       # テストがパスすれば、この部分がテストされていないことがわかる
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

つまり、テストがパスしてしまうと、raise以下のテストが実行されていないことがわかる。

これは問題。

解決するには、raise以下の使うテストを追加してあげれば良い

本当にここテストされてるかなー?って思ったらraiseを使ってみよう

###メンテナンスモードについて

開発者側からはアプリに入れてクライアント側からは入れなくしたい時は、メンテナンスモードをonにする。

heroku maintenance:on

これを解除するには、

$ heroku maintenance:off

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?