LoginSignup
5
3

More than 5 years have passed since last update.

Ruby on Rails Tutorial 9章 メモ

Last updated at Posted at 2018-04-23

基本用語

トークン

パスワードの平文と同じような秘匿されるべき情報を指す。
パスワードとトークンとの一般的な違いは、パスワードは使用者が自身で作成・管理する情報であるのに対し、トークンはコンピューターなどが生成した情報である点。

文法

timeヘルパー

>> 1.year.from_now
=> Wed, 21 Jun 2017 19:36:29 UTC +00:00
>> 10.weeks.ago
=> Tue, 12 Apr 2016 19:36:44 UTC +00:00

三項演算子(ternary operator)

if boolean?
  var = foo
else
  var = bar
end

# 三項演算子を使った場合
var = boolean? ? foo : bar

Remember me 機能

ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装していく。
この機能を使うと、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになる。

記憶トークンと暗号化

Railsのsessionメソッドを使ってユーザーIDを保存したが、この情報はブラウザを閉じると消えてしまう。
ここでは、セッションの永続化の第一歩として記憶トークン(remember token)を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)によるトークン認証にこの記憶トークンを活用する。

前述したように、sessionメソッドで保存した情報は自動的に安全が保たれるが、cookiesメソッドに保存する情報は残念ながらそのようにはなっていない。
特に、cookiesを永続化するとセッションハイジャックという攻撃を受ける可能性がある。
この攻撃は、記憶トークンを奪って、特定のユーザーになりすましてログインするというもの。
cookiesを盗み出す有名な方法は4通りある。
1. 管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す。
2. データベースから記憶トークンを取り出す。
3. クロスサイトスクリプティング (XSS) を使う。
4. ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る

  1. 最初の問題を防止するための方法は、Secure Sockets Layer (SSL) をサイト全体に適用して、ネットワークデータを暗号化で保護し、パケットスニッファから読み取られないようにする。
  2. 2番目の問題の対策としては、記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存するようにする。これは、生のパスワードをデータベースに保存する代わりに、パスワードのダイジェストを保存したのと同じ考え方に基づいている。
  3. 3番目の問題については、Railsによって自動的に対策が行われる。 具体的には、ビューのテンプレートで入力した内容をすべて自動的にエスケープする。
  4. 4番目のログイン中のコンピュータへの物理アクセスによる攻撃については、さすがにシステム側での根本的な防衛手段を講じることは不可能だが、二次被害を最小限に留めることは可能。 具体的には、ユーザーが(別端末などで)ログアウトしたときにトークンを必ず変更するようにし、セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名(digital signature)を行うようにする。

上で説明した設計やセキュリティ上の考慮事項を元に、次の方針で永続的セッションを作成することにする。

  1. 記憶トークンにはランダムな文字列を生成して用いる。
  2. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
  3. トークンはハッシュ値に変換してからデータベースに保存する。
  4. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
  5. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。

上の最後の手順は、ユーザーログインのときの手順と似ている。
ユーザーログインでは、メールアドレスをキーにしてユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを(authenticateメソッドで)確認する。
つまり、ここでの実装はhas_secure_passwordと似た側面を持つ。

記憶トークンの生成

remember_digest属性をUserモデルに追加する。

rails generate migration add_remember_digest_to_users remember_digest:string
rails db:migrate

urlsafe_base64メソッド

記憶トークンとして何を使うかを決める必要がある。
有力な候補として様々なものが考えられるが、基本的には長くてランダムな文字列であればどんなものでも構わない。
そのため、Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使う。
このメソッドは、A–Z、a–z、0–9、"-"、"_"のいずれかの文字 (64種類) からなる長さ22のランダムな文字列を返す (64種類なのでbase64と呼ばれている)。

>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"

2人のユーザーのパスワードが完全に同じであれば、記憶トークンが一意である必然性はなくなるが、現実にはそんなことはないので、記憶トークンによってセキュリティが高められる。
ありがたいことに、base64はURLを安全にエスケープするためにも用いられる(urlsafe_base64という名前のメソッドがあることからもわかる)ので、base64を採用すれば、アカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使えるようになる。

new_tokenメソッド

ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存する。
fixtureをテストするときに作成したdigestメソッドのように、新しいトークンを作成するためのnew_tokenメソッドを作成する。
この新しいdigestメソッドではユーザーオブジェクトが不要なので、このメソッドもUserモデルのクラスメソッドとして作成することにする。

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.rememberメソッド

このメソッドは、記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存する。
マイグレーションを行ってあるので、Userモデルには既にremember_digest属性が追加されているが、remember_token属性はまだ追加されていない。
そこで、user.remember_tokenメソッド(cookiesの保存場所)を使ってトークンにアクセスできるようにする必要がある。
しかも、トークンをデータベースに保存せずに実装する必要がある。
そのためには、安全なパスワードの問題のときと同様の手法でこれを解決します。
あのときは、「仮想の」password属性と、データベース上のセキュアなpassword_digest属性を使った。
仮想のpassword属性はhas_secure_passwordメソッドで自動的に作成できたが、今回はremember_tokenのコードを自分で書く必要がある。
これを実装するため、attr_accessorを使ってアクセス可能な属性を作成する。

最初にUser.new_tokenで記憶トークンを作成し、続いてUser.digestを適用した結果で記憶ダイジェストを更新する。

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 渡された文字列のハッシュ値を返す
  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


# # selfを使ってdigestとnew_tokenメソッドを定義(より「Ruby的に正しい」クラスメソッドの定義方法)
# class User < ApplicationRecord
# .
# .
# .
# # 渡された文字列のハッシュ値を返す
# def self.digest(string)
#   cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
#                                                 BCrypt::Engine.cost
#   BCrypt::Password.create(string, cost: cost)
# end
#  
# # ランダムなトークンを返す
# def self.new_token
#   SecureRandom.urlsafe_base64
# end
# .
# .
# .
# end


# # class << selfを使ってdigestとnew_tokenメソッドを定義(より「Ruby的に正しい」クラスメソッドの定義方法)
# class User < ApplicationRecord
# .
# .
# .
# class << self
#   # 渡された文字列のハッシュ値を返す
#   def digest(string)
#     cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
#                                                   BCrypt::Engine.cost
#     BCrypt::Password.create(string, cost: cost)
#   end
#     
#   # ランダムなトークンを返す
#   def new_token
#     SecureRandom.urlsafe_base64
#   end
# end
# .
# .
# .

# selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指すが、
# 上2つコードの文脈では、selfはUser「クラス」を指す。

rememberメソッドの1行目の代入は、selfというキーワードを使わないと、Rubyによってremember_tokenという名前のローカル変数が作成されてしまう。
この動作は、Rubyにおけるオブジェクト内部への要素代入の仕様によるもの。
今欲しいのはローカル変数ではないため、selfキーワードを与えると、この代入によってユーザーのremember_token属性が期待どおりに設定される。
rememberメソッドの2行目では、update_attributeメソッドを使って記憶ダイジェストを更新している。
このメソッドはバリデーションを素通りさせる
今回はユーザーのパスワードやパスワード確認にアクセスできないので、バリデーションを素通りさせなければならない。

ログイン状態の保持

permanentメソッド

user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する準備ができた。
これを実際に行うにはcookiesメソッドを使う。
このメソッドは、sessionのときと同様にハッシュとして扱える。
個別のcookiesは、1つのvalue (値) と、オプションのexpires (有効期限) からできている。
有効期限は省略可能。

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }

cookies.permanent[:remember_token] = remember_token

20年で期限切れになるcookies設定はよく使われるようになったため、permanentという専用のメソッドが追加された。

署名付きcookie

# 1
cookies[:user_id] = user.id

# 2
cookies.signed[:user_id] = user.id

# 3
cookies.permanent.signed[:user_id] = user.id
  1. このままではIDが生のテキストとしてcookiesに保存されてしまうので、アプリケーションのcookiesの形式が見え見えになってしまい、攻撃者がユーザーアカウントを奪い取ることを助けてしまう可能性がある。
  2. 1の状況を避けるために、署名付きcookieを使う必要がある。これは、cookieをブラウザに保存する前に安全に暗号化するためのもの。
  3. ユーザーIDと記憶トークンはペアで扱う必要があるので、cookieも永続化しなくてはならない。そこで、次のようにsignedとpermanentをメソッドチェーンで繋いで使う。
User.find_by(id: cookies.signed[:user_id])

cookiesを設定すると、以後のページのビューでこのようにcookiesからユーザーを取り出せるようになる。
cookies.signed[:user_id]では自動的にユーザーIDのcookiesの暗号が解除され、元に戻る。

署名されたユーザーIDがあれば記憶トークンは不要なのではないかと疑問に思うかもしれない。
しかし、記憶トークンがなければ、暗号化されたIDを奪った攻撃者は、暗号化IDをそのまま使ってお構いなしにログインしてしまう。
現在の設計では、攻撃者が仮に両方のcookiesを奪い取ることに成功したとしても、本物のユーザーがログアウトするとログインできないようになっている。

authenticated?メソッド

渡されたトークンがユーザーの記憶ダイジェストと一致することを確認する。
この一致をbcryptで確認するための様々な方法がある。

BCrypt::Password.new(remember_digest) == remember_token

このコードは、実に奇妙なつくりになっている。
bcryptで暗号化されたパスワードを、トークンと直接比較している。
bcrypt gemのソースコードを詳しく調べてみると、比較に使っている==演算子が再定義されている。
実際の比較をコードで表すと、次のようになっている。

BCrypt::Password.new(remember_digest).is_password?(remember_token)

以上の説明を元に、記憶トークンと記憶ダイジェストを比較するauthenticated?メソッドを、Userモデルの中に置けばよいのではないかと推測できる。

app/models/user.rb
 class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 渡された文字列のハッシュ値を返す
  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

authenticated?メソッドのローカル変数として定義したremember_tokenは、attr_accessor :remember_tokenで定義したアクセサとは異なる点に注意する。
今回の場合、is_password?の引数はメソッド内のローカル変数を参照している。
また、remember_digestの属性の使い方はself.remember_digestと同じになる。
実際、remember_digestの属性はデータベースのカラムに対応しているため、Active Recordによって簡単に取得したり保存したりできる。

rememberヘルパーメソッド

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

log_inのときと同様に、実際のSessionsヘルパーの動作は、rememberメソッド定義のuser.rememberを呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストをデータベースに保存する。
続いて、cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成する。

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メソッドでユーザーIDの永続cookiesを作成
    cookies.permanent[:remember_token] = user.remember_token # cookiesメソッドで記憶トークンの永続cookiesを作成
  end

  # 現在ログインしているユーザーを返す (いる場合)
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end
  .
  .
  .
end

ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されるが、current_userメソッドでは一時セッションしか扱っていないので、このままでは正常に動作しない。
永続セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要がある。

# このコードでも動作するが、今のままでは
# sessionメソッドもcookiesメソッドもそれぞれ2回ずつ使われてしまい、無駄
if session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
elsif cookies.signed[:user_id]
  user = User.find_by(id: cookies.signed[:user_id])
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

# ローカル変数を使ったコード
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
if (user_id = session[:user_id])

一見、上のコードは比較を行っているように見えるが、これは比較ではない。
このコードを言葉で表すと、「ユーザーIDがユーザーIDのセッションと等しければ...」ではなく、「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザーIDのセッションが存在すれば」となる。

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
  .
  .
  .
end

このコードでは、新しくログインしたユーザーは正しく記憶される。

ユーザーを忘れる

ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義する。

user.forgetメソッド

記憶ダイジェストをnilで更新する。

app/models/user.rb
 class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 渡された文字列のハッシュ値を返す
  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) # 記憶ダイジェストをnilで更新する
  end
end

log_outヘルパーメソッド

永続セッションを終了するには、forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す。
forgetヘルパーメソッドではuser.forgetを呼んでからuser_idとremember_tokenのcookiesを削除していることが分かる。

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

2つの目立たないバグ

1つ目のバグ

ユーザーは場合によっては、同じサイトを複数のタブ (あるいはウィンドウ) で開いていることもある。
ログアウト用リンクはログイン中には表示されないが、今のcurrent_userの使い方では、ユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーになってしまう。
これは、もう1つのタブで "Log out" リンクをクリックすると、current_userがnilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうから。
この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要がある。

2つ目のバグ

2番目の地味な問題は、ユーザーが複数のブラウザ (FirefoxやChromeなど) でログインしていたときに起こる。
例えば、Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、再度Chromeで同じページを開くと、この問題が発生する。
ユーザーがFirefoxからログアウトすると、user.forgetメソッドによってremember_digestがnilになる。
この時点では、Firefoxでまだアプリケーションが正常に動作しているはず。
このとき、以下のコードではlog_outメソッドによってユーザーIDが削除されるため、ハイライトされている2つの条件はfalseになる。
結果として、current_userメソッドの最終的な評価結果は、期待どおりnilになる。

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

一方、Chromeを閉じたとき、session[:user_id]はnilになる (これはブラウザが閉じたときに、全てのセッション変数の有効期限が切れるため)。
しかし、cookiesはブラウザの中に残り続けているため、Chromeを再起動してサンプルアプリケーションにアクセスすると、データベースからそのユーザーを見つけることができてしまう。

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

結果として、次のif文の条件式が評価される。

user && user.authenticated?(cookies[:remember_token])

このとき、userがnilであれば1番目の条件式で評価は終了するのだが、実際にはnilではないので2番目の条件式まで評価が進み、そのときにエラーが発生する。
原因は、Firefoxでログアウトしたときにユーザーのremember_digestが削除してしまっているにもかかわらず、Chromeでアプリケーションにアクセスしたときに次の文を実行してしまうから。

BCrypt::Password.new(remember_digest).is_password?(remember_token)

すなわち上のremember_digestがnilになるので、bcryptライブラリ内部で例外が発生する。
この問題を解決するには、remember_digestが存在しないときはfalseを返す処理をauthenticated?に追加する必要がある。

1つ目のバグの解決

テスト駆動開発は、この種の地味なバグ修正にはうってつけ。
そこで、2つのエラーをキャッチするテストから書いていくことにする。
統合テストを元に、redになるテストを作成する。

test/integration/users_login_test.rb
 require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "login with valid information followed by logout" do
    get login_path
    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_redirected_to root_url
    # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_path
    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

current_userがないために2回目のdelete logout_pathの呼び出しでエラーが発生し、テストスイートは redになる。

logged_in?がtrueの場合に限ってlog_outを呼び出すように変更する。

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

2つ目のバグの解決

2番目の問題についてだが、統合テストで2種類のブラウザをシミュレートするのは正直かなり困難。
その代わり、同じ問題をUserモデルで直接テストするだけなら簡単に行える。
記憶ダイジェストを持たないユーザーを用意し (setupメソッドで定義した@userインスタンス変数ではtrueになる)、続いてauthenticated?を呼び出す。
この中で、記憶トークンを空欄のままにしているが、記憶トークンが使われる前にエラーが発生するので、記憶トークンの値は何でも構わない。
このコードではBCrypt::Password.new(nil)でエラーが発生するため、テストスイートは redになる。

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

このテストをgreenにするためには、記憶ダイジェストがnilの場合にfalseを返すようにすれば良い。

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

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

ここでは、記憶ダイジェストがnilの場合にはreturnキーワードで即座にメソッドを終了している。
処理を中途で終了する場合によく使われるテクニック。
次のコードでも良いが、明示的にreturnする方が、コードが若干短くなる。

if remember_digest.nil?
  false
else
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

[Remember me]チェックボックス

ログインフォームにチェックボックスを追加する

チェックボックスは、他のラベル、テキストフィールド、パスワードフィールド、送信ボタンと同様にヘルパーメソッドで作成できる。
ただし、チェックボックスが正常に動作するためには、次のようにラベルの内側に配置する必要がある。

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' %>

    # [remember me] チェックボックスをログインフォームに追加する
      <%= 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>
app/assets/stylesheets/custom.scss
 .
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}

チェックボックスのオン・オフ

ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにする。
ログインフォームから送信されたparamsハッシュには既にチェックボックスの値が含まれている。
フォームに無効な値を入力して実際に送信すれば、ページのデバッグ情報で値を確認することもできる。
特に次の値は、チェックボックスがオンのときに’1’になり、オフのときに’0’になる。

params[:session][:remember_me]

paramsハッシュのこの値を調べれば、送信された値に基いてユーザーを記憶したり忘れたりできるようになる。

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

# 三項演算子での書き方
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
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) # [remember me] チェックボックスの送信結果を処理する
      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

[Remember me]のテスト

テストを書く理由の1つは、今行った実装のエラーをキャッチできるようにすること。
しかしもっと重要な理由は、ユーザーを永続化するコードの中心部分が、実はまだまったくテストされていないから。

[Remember me] ボックスをテストする

log_in_asヘルパーメソッド

ユーザーが記憶されるにはログインが必要。
そこで、テスト内でユーザーがログインできるようにするためのヘルパーメソッドを定義することから始める。
test/integration/users_login_test.rbでは、postメソッドと有効なsessionハッシュを使ってログインしたが、毎回このようなことをするのは面倒。
そこで、log_in_asというヘルパーメソッドを作成してテスト用の特別なログインができるようにし、無駄な繰り返しを排除する。
ログイン済みのユーザーをテストする方法はいくつかあるが、今回はコントローラの単体テストを使っていく。
具体的には、sessionメソッドを直接操作して、:user_idキーにuser.idの値を代入する。

def log_in_as(user)
  session[:user_id] = user.id
end

今回は既存のlog_inメソッドとの混乱を防ぐため、あえてメソッド名をlog_in_asとした。
このテスト用のメソッドを、test_helperファイルのActiveSupport::TestCaseクラス内で定義してみる。

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

log_in_asメソッド

次に統合テストでも同様のヘルパーを実装していく。
ただし統合テストではsessionを直接取り扱うことができないので、代わりにSessionsリソースに対してpostを送信することで代用する。
メソッド名は単体テストと同じ、log_in_asメソッドとする。

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ですが、今回は統合テストで扱うヘルパーなのでActionDispatch::IntegrationTestクラスの中で定義する。
これにより、私たち開発者は単体テストか統合テストかを意識せずに、ログイン済みの状態をテストしたいときはlog_in_asメソッドをただ呼び出せば良い、ということになる (これもダックタイピングの一種と言えそう)。

何かあったときにすぐに見つけられるよう、この2つのlog_in_asヘルパーを同じ場所にまとめておく。

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メソッドではキーワード引数のパスワードと [remember me] チェックボックスのデフォルト値を、それぞれ’password’と’1’に設定している。

[remember me] チェックボックスの動作を確認する

[remember me] チェックボックスの動作を確認するため、2つのテストを作成する。
チェックボックスがオンになっている場合とオフになっている場合のテスト。
ログインに成功すれば、cookies内部のremember_tokenキーを調べることで、ユーザーが保存されたかどうかをチェックできるようになる。
cookiesの値がユーザーの記憶トークンと一致することを確認できれば理想的なのだが、現在の設計ではテストでこの確認を行うことはできない。
コントローラ内のuser変数には記憶トークンの属性が含まれていますが、remember_tokenは実在しない「仮想」のものなので、@userインスタンス変数の方には含まれていない。
さしあたって、今は関連するcookiesがnilであるかどうかだけをチェックすればよいことにする。

実はもう1つ地味な問題がある。
テスト内ではcookiesメソッドにシンボルを使えない、という問題。

cookies[:remember_token]

上のコードは常にnilになってしまう。
ありがたいことに、文字列をキーにすればcookiesでも使えるようになる。

cookies['remember_token']
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

[Remember me]をテストする

実装した永続的セッションが動作するかどうかを手動で確認していた。
しかし実は、current_user内のある複雑な分岐処理については、これまでまったくテストが行われていない。
このようなとき、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むというテクニックを使う。
つまり、そのコードブロックがテストから漏れていれば、テストはパスしてしまうはず。
コードブロックがテストから漏れていなければ、例外が発生してテストが中断するはず。

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

この段階でテストを実行してみると、 greenになる。
このコードが正常でないことがわかった以上、これはもちろん問題になる。
また、log_in_asヘルパーメソッドでは、session[:user_id]と定義してしまっている。
このままでは、current_userメソッドが抱えている複雑な分岐処理を統合テストでチェックすることが非常に困難。
ただありがたいことに、以前作成した次のSessionsヘルパーのテストでcurrent_userを直接テストすれば、この制約を突破することができる。

touch test/helpers/sessions_helper_test.rb

テスト手順は以下の通りになる。
1. fixtureでuser変数を定義する
2. 渡されたユーザーをrememberメソッドで記憶する
3. current_userが、渡されたユーザーと同じであることを確認する

上の手順ではrememberメソッドではsession[:user_id]が設定されないので、これで問題となっている複雑な分岐処理もテストできるようになる。

test/helpers/sessions_helper_test.rb
 require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

  def setup
    @user = users(:michael)
    remember(@user)
  end

  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end

このとき、テストをもう1つ追加している。
このテストでは、ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に現在のユーザーがnilになるかどうかをチェックしている。
これによって、次のネストしたif文内のauthenticated?の式をテストする。

if user && user.authenticated?(cookies[:remember_token])

テストを追加した結果、テストが(期待されていたとおり)redになる。
ここまでできれば、current_userメソッドに仕込んだraiseを削除して元に戻すことで、テストがパスするはず。

current_userの複雑な分岐処理をテストできたので、今後は手動で1つ1つ確認しなくても、自信を持って回帰バグをキャッチできる。

デプロイとマイグレーション

トピックブランチ作成からデプロイとマイグレーションまで

git checkout -b advanced-login
# 作業
rails test
git add -A
git commit -m "Implement advanced login"
git checkout master
git merge advanced-login
git push
heroku maintenance:on
git push heroku
heroku run rails db:migrate
heroku maintenance:off

Herokuにデプロイしても、Heroku上でマイグレーションを実行するまでの間は一時的にアクセスできない状態 (エラーページ) になるため、トラフィックの多い本番サイトでは、このような変更を行う前にメンテナンスモードをオンにしておくことが一般的。

詰まったところ

rails sでのエラー

rails s
=> Booting Puma
=> Rails 5.1.4 application starting in development 
=> Run `rails server -h` for more startup options
[21521] Puma starting in cluster mode...
[21521] * Version 3.9.1 (ruby 2.4.1-p111), codename: Private Caller
[21521] * Min threads: 5, max threads: 5
[21521] * Environment: development
[21521] * Process workers: 2
[21521] * Preloading application
[21521] * Listening on tcp://localhost:8080
Exiting
/usr/local/rvm/gems/ruby-2.4.1/gems/puma-3.9.1/lib/puma/binder.rb:269:in `initialize': Address already in use - bind(2) for "127.0.0.1" port 8080 (Errno::EADDRINUSE)    
  .
  .
  .

解決策

lsof -wni tcp:8080
  COMMAND   PID     USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
  ruby    12991 ec2-user   11u  IPv4  25166      0t0  TCP 
  127.0.0.1:webcache (LISTEN)
  ruby    12993 ec2-user   11u  IPv4  25166      0t0  TCP 
  127.0.0.1:webcache (LISTEN)
kill -9 12991
kill -9 12993
rails s

参考文献

Address already in use - bind(2) (Errno::EADDRINUSE)

5
3
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
5
3