noggy’s blog

自分用の備忘録です。。。

9.1 Remember me機能

ここでは、ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装していく。

remember me機能は、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになる。

9.1.1 記憶トークンと暗号化

ここでは、セッションの永続化の第一歩として

  • 記憶トークン(remember token)を生成、
  • cookiesメソッドによる永続的cookiesの作成、
  • 安全性の高い記憶ダイジェスト(remember digest)によるトークン認証

をする。ここで、「トークン」とはPCが作ってくれるパスワードみたいなもの。

セッションハイジャックと対応

セッションハイジャック

前章では、sessionメソッドで保存した情報は自動的に安全が保たれるが、cookiesメソッドに保存する情報は安全に保たれず、セッションハイジャックという攻撃を受ける可能性がある。

この攻撃は、記憶トークンを奪って、特定のユーザーになりすましてログインするというもの。cookiesを盗み出す有名な方法は4通り
(1)管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
(2)DBから記憶トークンを取り出す
(3)クロスサイトスクリプティングXSS)を使う
(4)ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る

対応

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

永続的セッションの作成の方針

上記の設計やセキュリティ上の考慮事項を元に、次の方針で永続的セッションを作成する。
1.記憶トークンにはランダムな文字列を生成して用いる
2.ブラウザのcookiesにトークンを保存する時には、有効期限を設定する
3.トークンはハッシュ値に変換してからDBに保存する
4.ブラウザのcookiesに保存するユーザーIDは暗号化しておく
5.永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがDB内のハッシュ値と一致することを確認する。

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

remember_digest属性をUserモデルに追加

まず、remember_digest属性をUserモデルに追加する。記憶トークンの保存場所になる。
console

$ rails g migration add_remember_digest_to_users remember_digest:string

マイグレーション名がto_usersで終わっていることより、マイグレーションの対象がDBのusersテーブルであることをRailsに示している。
db/migrate/[timestamp]_add_remember_digest_to_users.rb

class AddRememberDigestToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :remember_digest, :string
  end
end

記憶ダイジェストはユーザーが直接読み出すことはないので、remember_digestカラムにインデックスを追加する必要はない。
DBに反映。

$ rails db:migrate
記憶トークンの生成の方針

ここで、記憶トークンの生成には(候補としていろいろなものが考えられるが)Ruby標準ライブラリにあるSecureRandomモジュールにあるurlsafe_base64メソッドを用いることにする。

$ rails c
>> SecureRandom.urlsafe_base64
=> "ランダムな文字列"

base64はURLを安全にエスケープするためにも用いられる。うーん、すごい!
base64を採用すれば、第10章でアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使えるようになる!

ユーザーの記憶に向けて

ユーザーを記憶するには、
(a)記憶トークンを作成して、
(b)その記憶トークンをダイジェストに変換し、DBに保存する
の2つをする。ここで
(a)の記憶トークンの作成にはurlsafe64_baseメソッドを利用すること、
(b)のダイジェストのDBでの保存先はremember_digestとすること
にしていた。なのであとは「記憶トークンをダイジェストに変換」することである。

じつは、fixtureをテストする時に8.2.4でdigestメソッドを既に作成していた。

  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

これを利用して、新しいトークンを作成するためのnew_tokenメソッドを作成できる。

この新しいdigestメソッドではユーザーオブジェクトが不要なので、このメソッドもUserモデルのクラスメソッドとして作成することにする。new_tokenメソッドを追加したコードは次の通り。
models/user.rb

  # 渡された文字列のハッシュを返す
  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
実装計画

さしあたっての実装計画として、user.rememberメソッドを作成することにする。このメソッドは

  • 「記憶トークン」を「ユーザー」と関連付ける
  • 「記憶トークン」をハッシュ化した「記憶ダイジェスト」をDBに保存する。

Userモデルには既にremember_digest属性(記憶トークンの保存場所)が追加されていたが、remember_token属性をDBに保存せずに、user.remember_tokenメソッド(cookiesの保存場所)を使ってトークンにアクセスできるようにする必要がある。

そのために、6.3の安全なパスワードの問題のときと同様の手法でこれを解決する。6.3では、「仮想の」password属性と、DB上のセキュアなpassword_digest属性を使った。仮想のpassword属性はhas_secure_passwordメソッドで自動的に作成できた。

しかし、今回はremember_tokenのコードを自分で書かないといけない。remember_tokenにはアクセスが必要なので、実装のため、次のようにattr_accessorを使う。
models/user.rb(rememberメソッドの作成とremember_tokenの定義)

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

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

①:User.new_token(新しく作成したランダムな文字列)をremember_tokenに保存。selfをつけるとクラス変数になるよ。
②:remember_tokendigestメソッドでハッシュ化(暗号化)して、Userモデルのremember_digestを更新。update_attributeを使うことでValidationを素通りしている。

9.1.2 ログイン状態の保持

user.rememberメソッドが動作するようになったので、「ユーザー」をDB内に「remember_digest」として保存できるようになった。ここでは、ユーザーの「暗号化済みID」と「記憶トークン」をブラウザの永続cookiesに保存して永続セッションを作成する。そして、この「記憶トークン」をハッシュ化したものと「remember_digest」が一致するユーザーを探す、すなわちユーザー認証を行う。

cookiesメソッド

このためにcookiesメソッドを使う。このメソッドはsessionのときと同様にハッシュとして扱える。個別のcookiesは、「value(値)」と「オプションのexpires(有効期限)」からできている(有効期限は省略可能)例えば次のように、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作成できる。

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

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

cookies.permanent[:remember_token] = remember_token←①

①:記憶トークンをcookiesに保存。

「ユーザーID」をcookiesに保存するには、sessionメソッドで使ったのと同じパターンを使う。

cookies[:user_id] = user.id

このままではIDが生のテキストとしてcookiesに代入されてしまい、アプリのcookies形式まる見えになってしまい、攻撃者がユーザーアカウントを奪い取る可能性がある。これを避けるために、署名付きcookieを使う。これは、cookieをブラウザに保存する前に安全に暗号化するもの。

cookies.signed[:user_id] = user.id

ユーザーIDと記憶トークンはペアで扱う必要があるので、cookieも永続かしなくてはならない。そこで、signedpermanentをメソッドチェーンで繋いで使う。

cookies.permanent.signed[:user_id] = user.id

以後のページのビューで、次のようにcookiesからユーザーを取り出せるようになる

User.find_by(id: cookies.signed[:user_id])←①

①:cookies.signed[:user_id]で自動的にユーザーIDのcookiesの暗号が解除され、元にもどる。うーん、すごい!

bcryptの利用

いまから「記憶トークン(remember_token)」をハッシュ化したものと「remember_digest」が一致することの実装を行う。この一致を確認する方法はさまざまある。secure_passwordのソースコードには、

BCrypt::Password.new(password_digest) == unencrypted_password

このような比較がある。このコードを参考にして次のようなコードを使う。

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

しかし本来なら、bcryptのハッシュは復号化できないはず。だが、bcrypt gemの機能によって、比較に使っている==演算子が再定義されていることが分かる。
実際の比較をコードで表すと、次のようになっている。

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

実際の比較では、==の代わりにis_password?という論理値メソッドが使われている。

まとめると

以上により、「記憶トークン」とDB内の「remember_digest」を比較するauthenticated?メソッドを、Userモデルの中に置けばよいと分かる。
app/models/user.rb

  # 渡された文字列のハッシュ値を返す
   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

  # 永続セッションのためにユーザーをDBに記憶
  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

①:ここのremember_tokenは、9.3のattr_accessor :remember_tokenで定義されたアクせサとは異なる点に注意!今回の場合、is_password?の引数はメソッド内のローカル変数を参照している。

もう1つ、remember_digestの属性の使い方にも注目!この使い方はself.remember_digetと同じである。実際、remember_digestの属性はDBのカラムに対応しているため、Active Recordによって簡単に取得したり保存できたりする。

これで、ログインしたユーザーを記憶する処理の準備が整った。rememberヘルパーメソッドを追加して、log_inと連携する。
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

上と同様に、cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成する。

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

①は次のコードであった

# 永続セッションのためにユーザーをDBに記憶
  def remember
    self.remember_token = User.new_token                                        
    update_attribute(:remember_digest, User.digest(remember_token))    
  end
リファクタリング

ただし、ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されているが、current_userメソッドでは一時セッションしか扱っておらず、このままでは正常に動作しない。

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

永続セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要がある。次のようにする。

def current_user
  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
end

上のコードでも、sessionメソッドとcookiesメソッドもそれぞれ2回ずつ使われてしまい、無駄がある。これを解消するためは、次のローカル変数を使う。

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

上のコードでは。

if (user_id = session[:user_id]) 

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

current_userへルパーを定義すると、次のようになる。
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        

ブラウザのcookiesを削除する手段が未実装なので、今はユーザーがログアウトできない。

9.1.3 ユーザーを忘れる

ユーザーがログアウトできるように、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドuser.forgetを定義する。このuser.forgetメソッドによって、user.rememberが取り消される。具体的には、記憶ダイジェスト(DB保存されている)をnilで更新する。
models/user.rb

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

このコードを使うと、永続セッションを終了できるようになる準備が整った。終了するには、forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す。次のコードでは、forgetヘルパーメソッドでは、user.forgetを呼んでからuser_idとremember_tokenのcookiesを削除している。
app/helpers/sessions_helper.rb

  # 渡されたユーザーでログインする
  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            

9.1.4 2つの目立たないバグ

小さなバグが2つ残っている。
1つ目のバグ
ユーザーが同じサイトを複数のタブで開いているとする。このとき、ユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーとなる。これは、もう1つのタブで"Log out"リンクをクリックすると、current_userがnilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうから。この問題を解消するには、ユーザーがログイン中の場合にのみログアウトさせるとよい。

2つの目のバグ
ユーザーが複数のブラウザ(FirefoxChrome)でログインしていたとき、例えば、Firefoxでログアウトし、
Chromeではロウアウトせずにブラウザを終了させ、再度Cromeで同じページを開くと、エラーとなる。FirefoxChromeを使った例で考える。ユーザーがFirefoxからログアウトすると、user.forgetメソッドによってremember_digestがnilとなる。この時点では、Firefoxでまだアプリが正常に動作しているはず。このとき、log_outメソッドによってuser_idが削除される。user_idが削除されたことにより、次のcurrent_userメソッドのuser_idの条件式で、どちらもfalseとなる。

# 記憶トークン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

結果として、current_userメソッドの最終的な評価結果は、nilとなる。

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

  # 記憶トークン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

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

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になるので、bcyptライブラリ内部で例外が発生。この問題を解決するには、remember_digestが存在しないときはfalseを返す処理authenticated?に追加する必要がある。

テスト

上の2つのバグ修正に、テスト駆動開発はうってつけ。
test/integation/users_login_test.rb

#ログアウト用
    delete logout_path                                                    
    assert_not is_logged_in?    
    assert_redirected_to root_url                                               
    #2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_out
    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

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

次にこのテストを成功させる。logged_in?がtrueの場合に限ってlog_outを呼び出すようにする。
cotrollers/sessions_controller.rb

  def destroy
    log_out if logged_in?  ←①追加                                                     
    redirect_to root_url                                                       
  end

①:ログアウトはログインしているときだけ

2番目のバグのテスト

統合テストで2種類のブラウザをシミュレートするのは困難。代わりに、同じ問題をUserモデルで直接テストする。
具体的には、記憶ダイジェストを持たないユーザーを用意し(setupメソッドで定義した@userインスタンスではtrueになる)、続いてauthenticated?を呼び出す。
test/models/user_test.rb

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

このテストでは、Bcrypt::Password.new(nil)でエラーが発生するため、失敗する。
このテストを成功するためには、記憶ダイジェストがnilの場合にfalseを返すようにするとよい。
app/models/user.rb

  # 渡されたトークンがダイジェストと一致したら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

ここでは、記憶ダイジェストがnilの場合にはreturnキーワードで即座にメソッドを終了している。
処理を中途で終了する場合によく使われるテクニック。これでテストは成功する。

|