1
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 チュートリアル9章 備忘録

Posted at

はじめに

railsチュートリアル9章の内容が頭に入りにくかったので、自分なりの言葉でまとめた備忘録です

目標

以下のようなsessionメソッドでは、ユーザーIDの一時保存はできるが、
ブラウザを閉じると、保存が破棄されるため、再ログインが必要となる。

session[:user_id] = user.id

一般的なサイトは、ログイン状態を保持するかどうかの確認があるケースが多いため、
cookiesメソッドを用いて、ブラウザを閉じても、ログイン状態を維持できる機能を追加し、
それを選択できるような状態にする。

cookiesメソッドの問題点

sessionメソッドで保存した内容は安全性の高い物となっているが、
cookiesメソッドで保存する場合は、内容がそのまま保存されるため、安全性が低い。
cookiesを盗む有名な方法は4種類あり、解決策は以下のとおりとなる。

①管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す。
Secure Sockets Layer(SSL)をサイト全体に適用して、ネットワークデータを暗号化で保護

②データベースから記憶トークンを取り出す。
記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存する

③クロスサイトスクリプティング(XSS)を使う。
Railsにより、ビューのテンプレートで入力した内容をすべて自動的にエスケープされる

④ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。
物理攻撃自体は防げないが、ユーザーがログアウトしたときにトークンを必ず変更するようにし、
セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名を行う

トークン:コンピューターが作成・管理する情報(パスワード:ユーザーが作成・管理する情報)

記憶トークンの生成

記憶トークンの内容は長く、ランダム性がある文字列であればok
⇒Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使用する
(A–Z、a–z、0–9、"-"、"_"の64種類から重複ありで22文字選び返すメソッド)
これを、Userモデルのクラスメソッドとして作成する
(オブジェクトのインスタンスを使用しないメソッドはクラスメソッドにするのが常道)

app/models/user.rb
def User.new_token
  SecureRandom.urlsafe_base64
end

記憶トークンのハッシュ化

記憶トークンをDBにそのまま保存すると、DBの情報を取られ得た際に問題となるため、
記憶トークンをハッシュ化し、ハッシュ化にはbcryptを用いる(gemのためインストール必要)
以下の表記で文字列をハッシュ化することができる。

BCrypt::Password.create(文字列, cost: コストパラメータ)

コストパラメータはハッシュを算出するための計算コストを指定する。
値が高い⇒パスワードの推測が困難になるが、計算処理が重くなる
したがって、テスト環境ではコストを下げ、本番環境ではコストを上げる事を考える

cost = if ActiveModel::SecurePassword.min_cost #テスト環境かどうか? テスト環境⇒true
         BCrypt::Engine::MIN_COST #最小のコスト(4)
       else
         BCrypt::Engine.cost #何もしていなければデフォルトのコスト(12)
       end

コストについての参考
上記を三項演算子を用いて表記すると

cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost

以上の内容を用いて、クラスメソッドを作成する。

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

ハッシュ化した記憶トークンのDB保存

まずはハッシュ化した記憶トークンを入れる場所が必要のため、
remember_digest属性をUserモデルに追加

ターミナル
$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate

user.remember_tokenでトークンにアクセスをしたいが、トークンをデータベースに保存はしたくない。
⇒attr_accessorを使って仮想の属性を作り出す
(has_secure_passwordメソッドの仮想のpassword属性と同じ考え)

app/models/user.rb
  attr_accessor :remember_token

remember_tokenにトークンを代入し、それをハッシュ化しDB(remember_digest)に保存するメソッドを追加する

app/models/user.rb
  def remember
    self.remember_token = User.new_token #remember_token = ではローカル変数扱いになってしまう
    update_attribute(:remember_digest, User.digest(remember_token))
  end

cookiesの保存

cookiesに記憶トークンとユーザーidを保存させる
有効期限(expires)は省略可能、今回は20年で設定している(よく使われる有効期限のひとつ)

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

cookiesの有効期限20年はよく使われている設定であり、Railsにもpermanentという専用メソッドが存在ため、
そのメソッドで書き直す。

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

記憶トークンはそのままの文字列で保存しても問題ないが、ユーザーidをそのまま保存すると、
攻撃者がユーザーアカウントを奪い取ることを助けてしまう可能性があり。
⇒署名付きcookieを使用する(cookieをブラウザに保存する前に安全に暗号化する)

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

以上の記述を行うことで、以下内容でユーザーを取り出せるようになる。

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

記憶トークンとDBの情報(remember_digest)の一致確認

渡されたトークンがユーザーの記憶ダイジェストと一致することを確認する方法として、
secure_passwordのソースコードを参考にする

secure_passwordのソースコード抜粋
BCrypt::Password.new(password_digest) == unencrypted_password

上記を参考に今回のケースであてはめる

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

上記の書き方では、ハッシュ化したもの == ハッシュ化前のものを比較しており、
ハッシュ化したものは復号化できないため、本来おかしい。
⇒bcrypt gemのソースコードで == の再定義が行われている

bcrypt_gemのソースコード抜粋
    # Compares a potential secret against the hash. Returns true if the secret is the original secret, false otherwise.
    def ==(secret)
      super(BCrypt::Engine.hash_secret(secret, @salt))
    end
    alias_method :is_password?, :==

また、 == はis_password?メソッドと同義のため、以下に書き直せる

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

以上より、記憶トークンと記憶ダイジェストを比較するauthenticated?メソッドを、Userモデルに記載
(remember_tokenはローカル変数であり、attr_accessor :remember_tokenとは関係なし)

app/models/user.rb
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

createコントローラへ記載

user.rememberを呼び出し、ユーザーidとトークンをcookiesに保存するメソッドを、sessions_helperに追加

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

createアクションにrememberヘルパーメソッドを追加

app/controllers/sessions_controller.rb
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

current_userメソッドの変更

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

上記では、一時セッションにしか対応していないため、永続セッションにも対応させる。
具体的には、session[:user_id]が存在すれば一時セッションからユーザーを取り出す。
それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要

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

このままでも動くが、user_idに対しsessionメソッドとcookiesメソッドを2回使っているので、1回で済むように、
if文をユーザーIDにユーザーIDのセッションを代入した結果、ユーザーIDのセッションが存在すればに変更し、
sessionヘルパーに記載する。

app/helpers/sessions_helper.rb
  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

ログアウト処理

ログアウトする方法は、cookiesの中身をdeleteすれば良い。
また、ログアウトと同時にDB内のハッシュ化したトークンをnilにする。
まずは、remember_digestカラムをnilに更新するメソッドをuserモデルに追加する。

app/models/user.rb
  def forget
    update_attribute(:remember_digest, nil)
  end

log_outヘルパーメソッドにforgetヘルパーメソッドを追記する(中身はまだ書いていない)

app/helpers/sessions_helper.rb
  def log_out
    forget(current_user) #追記
    session.delete(:user_id)
    @current_user = nil
  end

forgetヘルパーメソッドの中身を記載する

app/helpers/sessions_helper.rb
  def forget(user)
    user.forget #update_attribute(:remember_digest, nil)
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

目立たないバグ1

2つタブを開いた状態で、1つのタブでログアウトしたあとに、
もう一つのタブでログアウトするとエラーが発生

【原因】
1回目のログアウト時にcurrent_userがnilになっているため、
2回目のログアウトでforget(current_user)がエラーとなる

【対策】
ユーザーがログイン中の場合にのみログアウト

app/controllers/sessions_controller.rb
  def destroy
    log_out if logged_in? #変更
    redirect_to root_url
  end

目立たないバグ2

2つのブラウザ(Firefox、Chromeなど)でサイトを閲覧時、Firefoxでログアウトし、
Chromeを閉じ、同じサイトを開くとエラーが発生

【原因】
Firefoxでのログアウト時に、remember_digestがnilになる。
Chromeを閉じて、開いた際のuser_idの記憶は以下の状況になる

session[:user_id] ⇒ データなし
cookies.signed[:user_id] ⇒ データあり

その結果current_userのuser.authenticated?(cookies[:remember_token])部分で、
remember_digestがnilになっていることでエラーが発生する

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
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

【対策】
上記コード実行前に、remember_digestが空であればfalseを返す処理を追記する

app/models/user.rb
  def authenticated?(remember_token)
    return false if remember_digest.nil? #追記
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

ログイン状態を保持するかどうかのチェックボックス

まずは、ログインフォームにチェックボックスを追加したいため、
以下内容をフォーム部分に追記する

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

これにより、チェックボックスがオン = 1、チェックボックスがオフ = 0が返ってくる。
値の取得は以下でできる。

params[:session][:remember_me]

チェックボックスがオンのとき、cookiesメソッドによるログインの永続化を行いたいので、
1⇒remember(user) 0⇒forget(user)を呼び出すようにする

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

上記を三項演算子を用いてsessionコントローラのcreateアクションに追記する

app/controllers/sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email])
    if user && user.authenticate(params[:session][:password])
      log_in(user)
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      flash[:success] = "ログインに成功しました"
      redirect_to user
    else
      flash.now[:danger] = 'Eメールとパスワードの組み合わせが無効です'
      render 'new'
    end
  end
1
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
1
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?