Railsチュートリアル(第6版)　第9章　発展的なログイン機構

#第9章
第8章は基本的なログイン機構だったが、第9章ではブラウザを再起動してもログインできる「remember me」機能を追加する。
これには、**永続cookie**を使って機能を実装する。
さらにユーザの任意でログイン情報を記録できるようにする。

##Remember me 機能
ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになるもの。
また、remember meのチェックボックスをログインフォームに追加する。

いつものようにトピックブランチを作成し、作業開始。

`$ git checkout -b advanced-login`

###記憶トークンと暗号化
ややこしい話になってくるので、まとめる。
大事なのは、特にこの２つ
・記憶トークンの生成
・記憶ダイジェストによるトークン認証

記憶トークンを活用して、記憶ダイジェストを認証させる。

Q：トークンとパスワードの違って？
A：トークンはコンピューだが作成・管理する情報。パスワードはユーザーが作成・管理する情報


`session`メソッドで保存した情報は、安全性が高い一方で、`cookies`メソッドに保存する情報は危険性がある。
それは、セッションハイジャックという攻撃を受ける可能性がある点だ。
記憶トークンを盗み、ユーザーになりすましてログインするというもの。
cookiesを盗む方法は4種類

>（1）管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す1 。
（2）データベースから記憶トークンを取り出す。
（3）クロスサイトスクリプティング（XSS）を使う。
（4）ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。

①は、SSL化でOK
②は、記憶トークンをハッシュ化すればOK
③は、Railsで自動的に対策してくれるのでOK
④は、個々で対策が必要だけど、ログアウト時にトークンを変更するようにしてセキュリティ上重要な情報を表示する時はデジタル署名を使えばOK

永続的セッションの作成ポイント

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

usersテーブルに`remember_digest`属性を追加する。

```
$ rails generate migration add_remember_digest_to_users remember_digest:string
```

`to_users`とすることで、マイグレーション対象データベースが`users`テーブルであることをRailsに指示している。
今回、`remember_digest`属性は`string`としている。
マイグレーションが自動的に作られた。

```ruby:db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :remember_digest, :string
  end
end
```

記憶ダイジェストはユーザーが直接読みださないので、`remember_digest`カラムにインデックスは追加しない。
あとは、`$ rails db:migrate`してOK

記憶トークン作成時に、長くてランダムな文字列を作りたい。
こんな時には、Ruby標準ライブラリの`SecureRandom`モジュールにある`urlsafe_base64`を使うと良い。

このメソッドは64種類の文字からなる長さ22のランダムな文字列を返す。

```
$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"
```

同じパスワードを持つユーザーが複数いても問題ないのと同様、同じ記憶トークンを持つユーザーが複数いても問題ないが、トークンは一意であるほうがより安全。


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

```ruby:app/models/user.rb
  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end
```

次に、`user.remember`メソッドを作成するが、これは記憶トークンをユーザーと関連付けて、トークンに対応する記憶ダイジェストをデータベースに保存するというもの。
まだ、Userモデルには、`remember_token`属性はないため、`user.remember_token`メソッドを使い、トークンにアクセスするがトークンをデータベースに保存しない形で実装する。

今回は`remember_token`のコードを実装するにあたり、`attr_accessor`を使って「仮想の」属性を作成する。

```ruby:app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end
```

`remember`メソッド1行目の左辺に`self`というキーワードを使わないと、`remember_token`という名前のローカル変数が作成されるので、`self`を付けている。
`self`キーワードを与えることで、この代入によってユーザーの`remember_token`属性が設定される。
2行の`update_attribute`メソッドを使って、記憶ダイジェストを更新しいている。`update_attribute`メソッドはバリデーションを素通りする。今回はパスワードにアクセスできないので、素通りさせなければならない。

以上から
①`User.new_token`で記憶トークンを作成する。
②`User.digest`を適用した結果で記憶ダイジェストを更新する。

なので、`remember_token`にはbase64の結果が入り、`remember_digest`にはbase64をハッシュ化した値が入る。

演習
`User.digest`とか`User.new_token`とあるが、これのUserの部分はselfに置き換えられる。なので、`self.digest`や`self.new_token`となり、この場合のselfはUserクラスを指す。
selfは通常の文脈においては、Userモデルのユーザーオブジェクトのインスタンスを指すが、これはUserクラスを指すのでややこしい。。。。。。
(この辺りちょっとややこしいわ)
`class << self`から`end`で囲んであげれば、`def digest(string)`と`def new_token`という風に書ける。

###ログイン状態の保持
ここから、ユーザーの暗号化済IDと記憶トークンをブラウザの永続cookiesに保存することとする。
これをするために、`cookies`メソッドを使う。
これは、`value`(値)と`expires`(有効期限)からできており、有効期限は省略可能。下のコードでは有効期限を20年後に設定

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

今では、Railsに`permanent`という専用メソッドが追加された。
このコード使えば、すっきりとシンプルになる。

```
cookies.permanent[:remember_token] = remember_token
```

続いて、ユーザーIDをcookiesに保存するには、`session`メソッドと同様に
`cookies[:user_id] = user.id`
とすればOK

しかしながら、これではユーザーIDが生テキストとしてcookiesに保存され、非常に危険。
そのため、署名付きcookieを使おう。
`cookies.signed[:user_id] = user.id`
これは、cookieをブラウザに保存する前に安全に暗号化するためのもの。

そして、ユーザーIDと記憶トークンはペアで扱うため、cookieも永続化するが、`signed`とpermanent`はメソッドチェーンで繋げられる。
`cookies.permanent.signed[:user_id] = user.id`

cookiesを設定すれば、以後のビューで下のようにcookiesからユーザーを取り出せる。
`User.find_by(id: cookies.signed[:user_id])`
こうすることで、自動的にユーザーIDのcookiesの暗号が解除されて、元に戻る。
現在の設計では、攻撃者がユーザーIDと記憶トークンを盗み出来たとしても、本物のユーザーがログアウトすると、ログインできないようになっている。


次に、bcryptを使い`cookies[:remember_token]`が`remember_digest`と一致するかを確認する。
渡されたトークンがユーザーの記憶ダイジェストと一致するかだが、この一致確認をbcryptで行うが、方法は様々だ。

[secure_passwordのソースコード](https://github.com/rails/rails/blob/main/activemodel/lib/active_model/secure_password.rb)
上記から
`BCrypt::Password.new(password_digest) == unencrypted_password`
これを参考にしたものを
`BCrypt::Password.new(remember_digest) == remember_token`
に置き換える。

このコードは、bcryptで暗号化されたパスワードとトークンを**==**で直接比較しているようにみえる。
bcryptは複合化できないので、直接比較はしていない。
実は、比較に使っている**==**演算子は再定義されていて、実際の比較をコードにするとこんな感じ

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

==の代わりに、`is_password?`という論理値メソッドを使用している。

記憶トークンと記憶ダイジェストを比較する`authenticated?`メソッドをUserモデルに追加する。

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

`authenticated?`メソッドのローカル変数として定義してある`remember_token`は、`attr_accessor :remember_token`で定義されたアクセサとは異なる点に注意。

コントローラーに、ログインしたユーザーを記憶する処理の準備が整ったので、`remember`ヘルパーメソッドを追加、`log_in`と連携させる。

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

上で書いた`remember`メソッド読み出すため、下のコードで定義する。

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

ここで第8章で定義した`current_user`メソッドは一時セッションしか対応していないので、`session[:user_id]`があればここからユーザーを取り出し、なければ対応する永続セッションにログインするといった形にする。
すると下のようなコードになる。

```ruby:app/helpers/sessions_helper.rb
  # 記憶トークン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
```

・`session[:user_id]`を`user_id`に格納して、コードの重複を減らしている。

これで永続セッションによって、ブラウザを閉じて開きなおしてもログインし続ける。
しかし、まだブラウザのcookiesを削除する手段が未実装のため、ログアウトできない。

###ユーザーを忘れる
ユーザを忘れるためのメソッドを定義する。
`user.forget`メソッドで、`user.remember`を取り消せるようにする。
方法としては、記憶ダイジェストを`nil`で更新する。

```ruby:app/models/user.rb
  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
```

cookiesに保存されたユーザーIDと記憶トークンを削除するため、`forget`ヘルパーメソッドを定義する。
そして、`log_out`ヘルパーメソッドから`forget`ヘルパーメソッドを読み出せばOK

```ruby:app/helpers/sessions_helper.rb
  # 永続的セッションを破棄する
  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
```

これでテストは成功する。

###2つの目立たないバグ
実はバグが、2つある。
①同じサイトを複数のタブorウィンドウで開いているとき、一つのタブでログアウトし、もう一つのタブで再度ログアウトしようとするとエラーになる。
これは、ログアウトすることで`current_user`が`nil`となるため、`forget(current_user)`が失敗してしまうから。

②複数のブラウザでログインしてたとき、例えばFirefoxでログアウトし、Chromeではログアウトせずにブラウザを終了し、再度Chromeで同じページを開くと、この問題が発生する。
Firefoxからログアウトし、`user.forget`メソッドによって`remember_digest`が`nil`になる。一時セッションと永続クッキーがのif文がfalseとる。
なので、`current_user`メソッドの評価結果は`nil`になる。

一方のChormeでは、`session[:user_id]`は`nil`になるが、`cookies`はブラウザに残り、ブラウザを再起動したときにデータベースからユーザーを見つけることができる。
結果的に
`user && user.authenticated?(cookies[:remember_token])`
二番目の条件式でエラーになる。Firefoxでログアウトした時に、`remember_digest`を削除したのに、Chrome側では`BCrypt::Password.new(remember_digest).is_password?(remember_token)`を実行しようとするから。
解決策としては、`remember_digest`が存在しない時に`false`を返す処理を`authenticated?`に追加してあげる。

まずは、テストを書きエラーをキャッチするところから始める。

```ruby:test/integration/users_login_test.rb
    assert_redirected_to root_url
    # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_path
    follow_redirect!
```

`assert_redirected_to root_url`の後に`delete logout_path`を追記する。

これでテストが失敗するので、これをパスさせるには下記のようにログイン中だったら、ログアウトするようにすれば良い。

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

これで一つ目の問題はOK

二番目の問題は、統合テストで2つのブラウザをシミュレートするのが難しい。
なので、記憶ダイジェストを持たないユーザーを用意し、`authenticated?`メソッドを呼び出せば良い。そして、記憶トークンの値はなんでもOK

```ruby:test/models/user_test.rb
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
```

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

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

記憶ダイジェストが`nil`なら`return`で即座にメソッドを終了させる。

これでテストは成功する。


##[Remember me]チェックボックス
Remember meチェックボックスにチェックがあったらログインを保持するというもの。

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

```html: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 %>
```

チェックボックスをラベルの内側に配置しているのは、ラベルに指定されている範囲内であればどこを押しても、チェックボックスを押したときと同様の動作にするため。

次にCSSを整える。
CSSクラスの`checkbox`と`inline`は、Bootstrapにおいてチェックボックスとテキストとして同じ行に配置する。

```scss:app/assets/stylesheets/custom.scss
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

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

チェックボックスがオンなら1で、オフなら0となる。
`params[:session][:remember_me]`
とすることで、1か0の値が取れる。

そして、paramsのハッシュの値によって、if文で分岐させれば良いので

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

とできるが、「**三項演算子**」を使うことで1行にまとめられる。

`params[:session][:remember_me] == '1' ? remember(user) : forget(user)`

なのでセッションコントローラの`remember　user`を上のに置き換えればOK

三項演算子の書き方
`条件分 ? trueの時 : falseの時`

##[Remember me]のテスト
[Remember me]の機能が動作したので、テストを書いていく。

###[Remember me]ボックスをテストする
railsチュートリアルの著者によると、最初の実装をしたのようにしたとのこと

`params[:session][:remember_me] ? remember(user) : forget(user)`

しかし、0も1もRubyでは`true`になるので、上のコードは常にtrueになる。
このようなミスを防ぐためにも、テストを入れようってお話。

今までは、テスト内でユーザーがログインできるようにするために、`post`メソッドと有効な`session`ハッシュを使ってログインしていたが、毎回やるのは面倒なので、`log_in_as`というヘルパーメソッドを作成し、テスト用のログインを行う。これで無駄に繰り返していたpostとsessionの処理を排除できる。
今回は既存の`log_in`メソッドと名前の混乱を防ぐため、`log_in_as`というメソッド名にしてる。

まずは、ログイン済ユーザーを`session`に代入する。

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

これを`test_helper`ファイルの`ActiveSupport::TestCase`クラス内で定義する。

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

続いて、統合テストでも同様のヘルパーを実装するが、`session`を直接取り扱えないので、代わりにSessionsリソースに対して`post`を送信する。

メソッド名は、単体テストと同様に`log_in_as`とする。

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

`ActiveSupport::TestCase`と`ActionDispatch::IntegrationTest`の両方で`log_in_as`メソッドを定義しているのは、単体テストか統合テストか意識せず、`log_in_as`メソッドを呼び出せるようにしたため。

最後に、[Remember me]チェックボックスがオン、オフになっているかのテストを作成する。

オンの時
`log_in_as(@user, remember_me: '1')`

オフの時
`log_in_as(@user, remember_me: '0')`

実は、`1`は`remember me`のデフォルト値なので、省略できるが明示的に表記すると分かりやすくなる。

[remember me]チェックボックスのテスト

```ruby:test/integration/users_login_test.rb
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_empty cookies[:remember_token]
  end

  test "login without remembering" do
    # cookieを保存してログイン
    log_in_as(@user, remember_me: '1')
    delete logout_path
    # cookieを削除してログイン
    log_in_as(@user, remember_me: '0')
    assert_empty cookies[:remember_token]
  end
```

ここまででテストを実施すると、成功する。

###[Remember me]をテストする
`current_user`メソッドがきちんと動作しているか現時点で不明なのと、それに対するテストが書かれていない。

```ruby:app/helpers/sessions_helper.rb
    # 記憶トークン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
```

なので、途中で`raise`を仕込んでテストして通ってしまったらテストがされてないことになる。

そのため、`session_helper`に対するテストを入れる。

`$ touch test/helpers/sessions_helper_test.rb`
でテストファイルを作成する。

手順
①fixtureで`user`変数を定義
②渡されたユーザーを`remember`メソッドで記憶
③`current_user`が、渡されたユーザーと同じであるか確認

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

二つ目のテストでは、ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に、現在のユーザーがnilになるかをチェックしている。
このおかげで、if文の`if user && user.authenticated?(cookies[:remember_token])`の中にある`authenticated?`をテストしている。

`assert_equal`の書き方に関して
`assert_equal current_user, @user`
と書いてもOKのように思えるが、`assert_equal`の引数は

`assert_equal <期待する値>, <実際の値>`としなければならない。

従って、`assert_equal @user, current_user`
この順序で書けばOK。


演習

```
Failure:
SessionsHelperTest#test_current_user_returns_nil_when_remember_digest_is_wrong [/home/ubuntu/environment/sample_app/test/helpers/sessions_helper_test.rb:17]:
Expected #<User id: 762146111, name: "Michael Example", email: "michael@example.com", created_at: "2021-05-30 06:19:06", updated_at: "2021-05-30 06:19:09", password_digest: [FILTERED], remember_digest: "$2a$04$eIUEhCF4kZ1P25y7fpg9Rukzw0dJhCrTwiK3FJjQkJy..."> to be nil.
```

`current_user`が`nil`であることを期待されているが、`authenticated?`メソッドの箇所を削除したため、userオブジェクトが返ってきてしまいテストが失敗している。

#最後に
あとは、いつも通りGithubとHerokuにpushして終わり～。

因みにトラフィックの多い本番サイトでは、メンテナンスモードをオンしておくと良い。
メンテナンスモードオン→プッシュ→マイグレート→メンテナンスオフの流れ

```
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off
```


結構複雑になってきたということを実感できる章だと思う。