rails チュートリアル９章　備忘録

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

#目標

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

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

#cookiesメソッドの問題点

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

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

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

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

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

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


```ruby:app/models/user.rb
def User.new_token
  SecureRandom.urlsafe_base64
end
```
#記憶トークンのハッシュ化
記憶トークンをDBにそのまま保存すると、DBの情報を取られ得た際に問題となるため、
記憶トークンをハッシュ化し、ハッシュ化にはbcryptを用いる（gemのためインストール必要）
以下の表記で文字列をハッシュ化することができる。

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

```ruby
cost = if ActiveModel::SecurePassword.min_cost #テスト環境かどうか？ テスト環境⇒true
         BCrypt::Engine::MIN_COST #最小のコスト(4)
       else
         BCrypt::Engine.cost #何もしていなければデフォルトのコスト(12)
       end
```
[コストについての参考](https://www.rubydoc.info/github/codahale/bcrypt-ruby/BCrypt/Engine#DEFAULT_COST-constant)
上記を三項演算子を用いて表記すると

```ruby
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
```
以上の内容を用いて、クラスメソッドを作成する。

```ruby: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属性と同じ考え）

```ruby:app/models/user.rb
  attr_accessor :remember_token
```
remember_tokenにトークンを代入し、それをハッシュ化しDB(remember_digest)に保存するメソッドを追加する


```ruby: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年で設定している（よく使われる有効期限のひとつ）

```ruby
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という専用メソッドが存在ため、
そのメソッドで書き直す。

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

```ruby
cookies.permanent[:remember_token] = remember_token
cookies.permanent.signed[:user_id] = user.id
```
以上の記述を行うことで、以下内容でユーザーを取り出せるようになる。

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

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

```ruby:secure_passwordのソースコード抜粋
BCrypt::Password.new(password_digest) == unencrypted_password
```
上記を参考に今回のケースであてはめる

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

```ruby: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?メソッドと同義のため、以下に書き直せる

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

```ruby: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に追加

```ruby: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ヘルパーメソッドを追加

```ruby: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メソッドの変更
```ruby
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end
```
上記では、一時セッションにしか対応していないため、永続セッションにも対応させる。
具体的には、session[:user_id]が存在すれば一時セッションからユーザーを取り出す。
それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要

```ruby
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ヘルパーに記載する。

```ruby: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モデルに追加する。

```ruby:app/models/user.rb
  def forget
    update_attribute(:remember_digest, nil)
  end
```
log_outヘルパーメソッドにforgetヘルパーメソッドを追記する（中身はまだ書いていない）

```ruby:app/helpers/sessions_helper.rb
  def log_out
    forget(current_user) #追記
    session.delete(:user_id)
    @current_user = nil
  end
```
forgetヘルパーメソッドの中身を記載する

```ruby:app/helpers/sessions_helper.rb
  def forget(user)
    user.forget #update_attribute(:remember_digest, nil)
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end
```
#目立たないバグ１
2つタブを開いた状態で、1つのタブでログアウトしたあとに、
もう一つのタブでログアウトするとエラーが発生

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

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

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

#目立たないバグ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になっていることでエラーが発生する

```ruby
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
```

```ruby
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
```
【対策】
上記コード実行前に、remember_digestが空であればfalseを返す処理を追記する

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


#ログイン状態を保持するかどうかのチェックボックス
まずは、ログインフォームにチェックボックスを追加したいため、
以下内容をフォーム部分に追記する

```erb: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が返ってくる。
値の取得は以下でできる。

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

```ruby
if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end
```
上記を三項演算子を用いてsessionコントローラのcreateアクションに追記する

```ruby: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
```