紙一重の積み重ね

アラフォーのエンジニアがなれる最高の自分を目指して、学んだことをこつこつ情報発信するブログです。

【9章】Ruby on Railsチュートリアル演習まとめ&解答例【9.1 Remember me】

はじめに

Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版の 9章 9.1 Remember meの演習まとめ&解答例です。

個人の解答例なので、誤りがあればご指摘ください。

9.1.1 記憶トークンと暗号化

本章での学び

永続cookiesを使って、ブラウザを再起動した後でもすぐにログインできる機能を実装する。

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

セキュリティ上の考慮事項

cookiesを永続化するとセッションハイジャック攻撃を受ける可能性がある。 記憶トークンを奪って、特定のユーザになりすましてログインする。 記憶トークンを奪う手段は以下4通り。

1.管理の甘いネットワークを通過するネットワークパケットから、パケットスニッファを使って直接cookieを取り出す。 対策:SSLをサイト全体に適用して、ネットワークデータを暗号化する。

2.DBから記憶トークンを取り出す。 対策:記憶トークンそのものを保存するのではなく、ハッシュ値を保存する。

3.クロスサイトスクリプティングを使う。 対策:Railsによって自動的に対策が行われる。具体的にはビューで入力された内容をすべて自動的にエスケープする。

4.ユーザーがログインしているPCやスマホを直接操作してアクセスを奪いとる。 対策:対策は不可能。二次被害を最小限に留めるために、ユーザが別端末でログアウトした際には必ずトークンを変更する。セキュリティ上重要になる可能性がある情報を表示する際には、デジタル署名を表示する。

永続セッションの実装方針

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

remember_digest属性をユーザモデルに追加

usersのデータモデルに、remember_digest属性を追加する。

image

yokoyan:~/workspace/sample_app (advanced-login) $ rails generate migration add_remember_digest_to_users remember_digest:string
Running via Spring preloader in process 2174
Expected string default value for '--jbuilder'; got true (boolean)
      invoke  active_record
      create    db/migrate/20170514215307_add_remember_digest_to_users.rb

マイグレーション用のファイルが自動生成される。 記憶ダイジェストはユーザーが直接呼び出すことはないため、remember_digestカラムにインデックスは追加しない。

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

rails db:migrateで適用する。

yokoyan:~/workspace/sample_app (advanced-login) $ rails db:migrate
== 20170514215307 AddRememberDigestToUsers: migrating =========================
-- add_column(:users, :remember_digest, :string)
   -> 0.0050s
== 20170514215307 AddRememberDigestToUsers: migrated (0.0052s) ================

記憶トークンの生成

Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを採用する。 A-Z,a-z,0-9,-,、,_のいずれかの文字(64文字)からなる長さ22のランダムな文字列を返す。 64文字だからbase64。

yokoyan:~/workspace/sample_app (advanced-login) $ rails console
Running via Spring preloader in process 1128
Loading development environment (Rails 5.0.0.1)
>> SecureRandom.urlsafe_base64
=> "Dzw6pg00KzZUtdgQ2tm42Q"

fixture内のdigestメソッド内に定義する。 Userオブジェクトが不要であるため、インスタンスメソッドではなく、Userモデルのクラスメソッドとして定義する。

  def User.new_token
    SecureRandom_urlsafe_base64
  end

記憶トークンのアクセサーを定義する

Userモデル内に、attr_accessorで属性を定義すると、対応するアクセサーメソッド(setter、getter)が自動生成される。 今回は、記憶トークン(remember_token)のアクセサーを定義する。

  attr_accessor :remember_token

記憶ダイジェストをデータベースに保存する

user.rememberメソッドにて実現する。

  • 記憶トークン(remember_token)とユーザを関連付ける
  • トークンに対応する記憶ダイジェスト(remember_digest)をデータベースに保存する
    • update_attributeメソッドにて記憶ダイジェストを更新する

演習1

コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。

?> user = User.first
  User Load (0.5ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-07 21:50:35", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...", remember_digest: nil>
>> user.remember
   (0.2ms)  begin transaction
  SQL (0.4ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", 2017-05-16 04:16:50 UTC], ["remember_digest", "$2a$10$0Nw3IJy2BwvBcijbUx5tKuxGEuyYmEFYcz0lcr.6Dca3FaNii7Lp."], ["id", 1]]
   (13.1ms)  commit transaction
=> true
>> user.remember_token
=> "rGF2zWPxkxnQzJmrZfT9QQ"
>> user.remember_digest
=> "$2a$10$0Nw3IJy2BwvBcijbUx5tKuxGEuyYmEFYcz0lcr.6Dca3FaNii7Lp."

演習2

リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。 しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、リスト 9.4 (ややわかりにくい) や、リスト 9.5 (非常に混乱する) の実装でも、正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。 わかりにくさの原因の一部はこの点にあります。

修正前のソース。

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

参考ページ

以下がわかりやすかった。 Ruby 初級者のための class << self の話 (または特異クラスとメタクラス)

特異メソッド方式

リスト9.4の実装方法。def self.class_methodのように、メソッド名の前にクラスメソッドを定義する対象のクラス名をつけて定義する。 この場合のselfは、Userクラスを表す。

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

テスト結果がgreenになることを確認。

yokoyan:~/workspace/sample_app (advanced-login) $ rails test
Running via Spring preloader in process 1992
Started with run options --seed 20784

  23/23: [==============================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.53597s
23 tests, 68 assertions, 0 failures, 0 errors, 0 skips

特異クラス方式

リスト9.5の実装方法。 class << selfと書いた行から、endまでの間に、def class_methodのようにクラス名を書かずにインスタンスメソッドと同じようなメソッド定義を書いていく。 この間に書かれたものは、全てクラスメソッドとして定義される。 特異メソッド方式のように、都度self.と記載しなくて良い。

class User < ApplicationRecord
  attr_accessor :remember_token
  ・・・略・・・
  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 # この範囲がクラスメソッド
 ・・・略・・・
end

テスト結果がgreenになることを確認。

yokoyan:~/workspace/sample_app (advanced-login) $ rails test
Running via Spring preloader in process 2069
Started with run options --seed 32363

  23/23: [==============================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.55466s
23 tests, 68 assertions, 0 failures, 0 errors, 0 skips

9.1.2 ログイン状態の保持

本章での学び

cookiesを20年間保持する

  • cookiesはハッシュとして扱える。
  • 個別のcookiesは、ひとつのvalue(値)と、expires(有効期限)からできている。
    • expiresの省略も可能。

20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができる。

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

上記のコードは以下のコートど同じ意味になる。

cookies.permanent[:remember_token] = remember_token

よく使われるため、Railsのpermanentメソッドとして追加された。 rails/actionpack/lib/action_dispatch/middleware/cookies.rb

【helper】ユーザーIDをcookieに保存する

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

cookies[:user_id] = user.id

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

cookies.signed[:user_id] = user.id

【helper】永続化した署名付きcookieにユーザーIDを保存する

ユーザーIDと記憶トークンはペアで扱う必要がある。 cookieを永続化するために、signedpermanentをメソッドチェーンで結ぶ。

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

【Model】永続化した署名付きcookieからユーザーIDを取り出す

cookiesを設定すると、ページのビューから取り出すことが可能になる。

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

渡された記憶トークンとユーザーの記憶ダイジェストの比較

GitHubのsecure_password.rbのソースを参考にする。

記憶ダイジェストと、暗号化されていないパスワード(unencrypted_password)を比較している。

    module InstanceMethodsOnActivation
      # Returns +self+ if the password is correct, otherwise +false+.
      #
      #   class User < ActiveRecord::Base
      #     has_secure_password validations: false
      #   end
      #
      #   user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
      #   user.save
      #   user.authenticate('notright')      # => false
      #   user.authenticate('mUc3m00RsqyRe') # => user
      def authenticate(unencrypted_password)
        BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
      end

上記のやり方を参考にして、記憶ダイジェストと記憶トークンの比較を行う。

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

本処理は、Userモデルにauthenticated?メソッドとして定義する。

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

以下、注意点。

  • authenticated?メソッドの引数である、remember_tokenは、ローカル変数。Userモデル内で定義したアクセサattr_accessor :remember_tokenとは異なる。
  • Password.newメソッドの引数である、remember_digestは、Userモデルが持つ変数。self.remember_digestと意味は同じ。remember_digest属性は、Usersテーブルのカラムであるため、Active Recordによって取得が可能。

【controller】セッションコントローラでログインしてユーザを保持する

sessions_controllercreateアクション内で、ログイン処理終了後、 sessions_helperで定義したヘルパーメソッドremember userを呼び出す。

if user && user.authenticate(params[:session][:password])
  log_in user
  remember user
  redirect_to user

【helper】セッションヘルパーでユーザを保存する

コントローラであるsessions_controllerから呼び出されるヘルパーのsessions_helper内で、

  • Userモデルのrememberメソッドを呼び出す
    • 記憶トークンの生成
      • User.new_token
        • base64でランダムなトークンを返す
    • 記憶トークンを記憶ダイジェストに変換
      • User.digest(rememer_token)
        • BCryptでハッシュ化
    • 記憶ダイジェストの値で、Usersテーブルを更新
      • update_attribute(:remember_digest, 記憶ダイジェストの値)
  • cookieの永続化
    • cookies.permanent
  • 署名付きcookieの生成
    • cookies.signed[:user_id]
  • 永続化したcookieへ記憶トークンを保存
    • cookies.permanent[:remember_token]

【helper】永続セッションのcurrent_userを更新する

sessions_helper内で定義している、current_userヘルパーは、一時セッションを使用している。

@current_user ||= User.find_by(id:session[:user_id])

永続セッションの場合 - session[:user_id]が存在すれば、一時セッションからユーザーを取得する。 - cookies[:user_id]が存在すれば、永続セッションからユーザーを取得する。

if (user_id = session[:user_id])
  # 一時セッションからユーザーを取り出す
elsif (user_id = cookies[:user_id])
  # 永続セッションからユーザーを取り出す
  if # ユーザーが存在してかつ、永続セッションの中の記憶トークンがDBの値と一致する
    # ログイン処理
  end
end

動作確認

上記コードを実装して、ログインすると、cookieの有効期限が20年後になっている。 また、ブラウザを閉じて再度アプリケーションにアクセスすると、ログイン状態となっている。 (現状では、ログアウトできないためログインしっぱなしとなる)

image

演習1

ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。

動作確認のキャプチャ参照。 remember_tokenと、暗号化されたuser_idがあることを確認。

演習2

コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。

?> user = User.first
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-21 22:01:07", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...", remember_digest: "$2a$10$JG8CGfVQNaJolnazSyxDJe7brrf/3TNExvNi2lWYWir...">
>> user.remember
   (0.1ms)  begin transaction
  SQL (0.6ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", 2017-05-23 21:33:36 UTC], ["remember_digest", "$2a$10$wX/tlvvmb1BNA/2dy7qOaugLTQgVGP6Ojm2nskWTpe5RniAnijjmq"], ["id", 1]]
   (16.5ms)  commit transaction
=> true
>> user.authenticated?(user.remember_token)
=> true

9.1.3 ユーザーを忘れる

本章での学び

【モデル】ユーザーのログイン情報を破棄する

Userモデルにて、データベースで保持しているremember_digestnilで更新する。

def forget
  update_attribute(:remember_digest, nil)
end

【ヘルパー】永続セッションを破棄する

sessions_helperforgetヘルパーメソッドにて、以下の処理を行う。

  • モデルで定義したUser.fogetを呼び出す
    • データ・ベースで保持しているremember_digestnilに更新する
  • cookies内からuser_idを削除
  • cookies内からremember_tokenを削除
def forget(user)
  user.forget
  cookies.delete(:user_id)
  cookies.delete(:remember_token)
end

【ヘルパー】ログアウトする

sessions_helperlogoutヘルパーメソッドにて、以下の処理を行う。

  • forgetヘルパーメソッドの呼び出し
  • sessionからuser_idを削除
  • @current_usernilに更新
def logout
  forget(current_user)
  session.delete(:user_id)
  @current_user = nil
end

演習1

ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。

ログアウト前のcookiesの状態。 image

ログアウト後のcookiesの状態。 cookiesが消えていることを確認。 image

9.1.4 2つの目立たないバグ

本章での学び

バグその1

再現手順
  • 同じサイトを複数のタブで開く
  • タブAでログアウトする image.png
  • タブBでログアウトリンクをクリックすると、エラーになる image.png
原因

タブAでログアウトしたタイミングで、current_usernilになってしまうため。

@current_user = nil

タブBのforget(current_user)の引数にnilが渡され、undefined method `forget' for nil:NilClassエラーが発生している。

対策

sessions_controller.rbdestroyメソッドを修正する。 current_usernilではない場合(ログイン状態の場合)に、log_outメソッドを呼び出す。

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end

バグその2

再現手順
  • 同じユーザでFirefoxとChromeでログインする image

  • Firefoxでログアウトする image

  • Chromeではログアウトせずにブラウザを終了する

  • 再度、Chromeでアクセスするとエラーが発生する。
    • BCrypt::Errors::InvalidHash: BCrypt::Errors::InvalidHash: invalid hashが発生。 image.png
原因

ブラウザAでログアウトしたタイミングで、remember_digestがDBから削除されているにもかかわらず、 ブラウザBでcookiesが残っているため、DBからユーザが取得できてしまう。 結果、ブラウザB側のBCryptの処理でremember_digestを参照しようとしてエラーが発生している。

対策

remember_digestが存在しない(値がnil)場合は、処理を継続させない。

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

演習1

リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。

バグその1を参照。

演習2

リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。

バグその2を参照。

演習3

上のコードでコメントアウトした部分を元に戻し、テストスイートが red から greenになることを確認しましょう。

修正後、テストがgreenになることを確認。

yokoyan:~/workspace/sample_app (advanced-login) $ rails test
Running via Spring preloader in process 1713
Started with run options --seed 42203

  24/24: [==============================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.25789s
24 tests, 69 assertions, 0 failures, 0 errors, 0 skips

おわりに

9章から急に難易度が上がりました。 MVCモデルのうち、どこを修正しているのかを意識しないと迷子になるな・・・。 sessionやcookieはWEBアプリケーションに欠かせないものなので、 この章は気合を入れて理解に努めます。