【自分用】rails チュートリアル 9章

やること
ブラウザを再起動した後でもすぐにログインできる機能の実装
そのためにユーザーの情報を長く記憶する方法、任意でユーザー情報を長く記憶する方法を学ぶ

用語
・トークン
パスワードのようなもの
パスワードはユーザーが作成・管理するのに対し、トークンは機械側が作成・管理する

・ハッシュ値
ハッシュ関数から返される戻り値（ハッシュ関数とは入力したデータに対するデタラメに見える値を返す関数）
入力されたデータを元にして作成されるデタラメに見える値
同じデータからは同じハッシュ値が生まれる
ハッシュ値からは元データを特定できない

・マイグレーション
Rubyの記述(マイグレーションファイル＝データベースの設計図)でデータベースのテーブル操作（作成、削除、カラムの追加削除、名前やデータ型変更など）をするもの

・ダイジェスト
ほぼハッシュ

・bcrypt
パスワード専用ハッシュ関数

高度なログイン機能
最近のwebサービスでは、ユーザーのログイン情報を任意で記憶しておき、ブラウザを再起動した後でもすぐにログインできる機能(remember me)を備えている
そこで、永続cookie(permanent cookies)を使って実現する
具体的には、まずユーザーのログイン情報を長期間記憶する方法について学ぶ
その後、[remember me]チェックボックスを使って、ユーザーの任意でログイン情報を記憶する方法を学ぶ

9.1 Remember me機能
ユーザーのログイン状態をブラウザを閉じた後でも有効にするremember me機能実装していく
トピックブランチ作成
$ git switch -c advanced-login

9.1.1 記憶トークンと暗号化
８章で使っていたsessionメソッドはブラウザを閉じるとユーザーIDが消えてしまう
セッションの永続化の第一歩として記憶トークン(remember token)を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)によるトークン認証にこの記憶トークンを活用する。
トークンは、パスワードのような秘密情報
パスワード...ユーザーが作成・管理する情報
トークン...コンピューターが作成・管理する情報

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

上の最後の手順はログインのときの手順と似ている
ログインのときは、メールアドレスをキーにしてユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することをauthenticateメソッドで確認する
has_secure_passwordと似ている

図9.1 remember_digest属性をUserモデルに追加する
↑のデータモデルをアプリに追加するためマイグレーション生成
$ rails generate migration add_remember_digest_to_users remember_digest:string
usersテーブルが対象。string型のremember_digest属性追加

記憶ダイジェストを指定してユーザーを取得することはないので、remember_digestカラムにインデックス追加する必要はないので↑のマイグレーションはそのまま使う
$ rails db:migrate

記憶トークンとして、長くてランダムな文字列を出力するRuby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使う
同じパスワードを持つユーザーがいてもいいが、トークンは一意である方がセキュリティ上よい
base64を採用することで後のアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使える

ユーザーを記憶する手順
記憶トークン作成
その記憶トークンをダイジェストに変換
それをDBに保存
fixtureをテストするときにdigestメソッドをすでに作成してあるので、新しいトークンを生成するためのnew_tokenメソッドを作成できる
このメソッドではユーザーオブジェクトが不要。よってUserモデルのクラスメソッドとして作成する
リスト9.2 トークン生成メソッド作成

次にuser.rememberメソッドを作成する
これは記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをDBに保存する
Userモデルにはすでにremember_digest属性があるが、remember_token属性はまだない
user.remember_tokenメソッドを使ってトークンにアクセスできるようにして、かつ、トークンをDBに保存せずに実装する必要がある
6.3のパスワードの実装と同様の手法で解決する
仮想のpassword属性とDB上のセキュアなpassword_digest属性の２つを使った
仮想のpw属性はhas_secure_passwordメソッドで自動的に作成されたが、今回はremember_tokenのコードを自分で書く
これを実装するため、4.4.5で出たattr_accessorを使って仮想の属性を使う

def remember
  self.remember_token = ...
  update_attribute(:remember_digest, ...)
end
１行目のselfキーワードを使わないとrubyによってremember_tokenというローカル変数が作成されてしまう
2行目ではupdate_attributeメソッドを使って記憶ダイジェストを更新している。このメソッドはバリデーションを素通りさせる。今回はユーザーのpwやpw確認にアクセスできないので素通りさせなければならない

最初にUser.new_tokenで記憶トークン作成し、User.digestを適用した結果で記憶ダイジェストを更新する
リスト9.3 rememberメソッドをUserモデルに追加

9.1.2 ログイン状態の保持
user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesメソッドに保存して、永続セッションを作成する準備ができた
cookiesメソッドを使う。これはsessionのときと同様にハッシュとして扱える
個別のcookiesは１つの値(value)とオプションの有効期限(expires)(省略可能)からできている
cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }
railsのtimeヘルパーで20年で期限切れになるよう設定
permanentメソッドを使うとシンプルに書ける
cookies.permanent[:remember_token] = remember_token
↑のコードで期限を20年に設定している

ユーザーIDをcookiesに保存するにはsessionメソッドで使ったのと同じように(リスト8.14)
cookies[:user_id] = user.id
このままではIDが生の状態でcookiesに保存されてしまい攻撃者が奪い取りやすい
これを避けるため暗号化cookieを使い、cookieをブラウザに保存する前に安全に暗号化する
cookies.encrypted[:user_id] = user.id

ユーザーIDと記憶トークンはペアで扱うのでcookieも永続化する
encryptedとpermanentをメソッドチェーンでつないで使う
cookies.permanent.encrypted[:user_id] = user.id

cookiesを設定すると、以後のページのビューでcookiesからユーザーを取り出せるようになる
User.find_by(id: cookies.encrypted[:user_id])
cookies.encrypted[:user_id]でユーザーIDのcookiesの暗号が解除され元に戻る
bcryptを使ってcookies[remember_token]がremember_digestと一致することを確認する

渡されたトークンがユーザーの記憶ダイジェストと一致することをbcryptで確認
railsの古いバージョンでは、セキュアなパスワードのソースコードで次のような比較を行なっている
BCrypt::Password.new(password_digest) == unencrypted_password
今回は↑を参考に↓のようにする
BCrypt::Password.new(remember_digest) == remember_token
bcryptで暗号化されたpwをトークンと直接比較している
==で比較する際にダイジェストを復号化しているのではなく、bcrypt gemの新しいソースコードでセキュアなpwの処理部分を見ると、比較に使う==演算子が再定義されている。==による比較が↓と同等
BCrypt::Password.new(remember_digest).is_password?(remember_token)
実際の比較では==の代わりにis_password?論理値メソッドが使われている

記憶トークンと記憶ダイジェストを比較するauthenticated?メソッドをUserモデルの中におく
これはhas_secure_passwordで提供されるユーザー認証用のauthenticated?メソッドと似ている(リスト8.15)
authenticated?メソッドは記憶ダイジェストと結びついているが、他の用途にも応用できる（10章）

リスト9.6 authenticated?メソッドをUserモデルに
ローカル変数として定義したremember_tokenは、リスト9.3のattr_accessor :remember_tokenで定義したアクセサとは異なる
今回の場合、is_password?の引数はメソッド内のローカル変数を参照している
remember_digestの使い方はself.remember_digestと同じで、6章のnameやemailの使い方と同じ
remember_digestの属性はDBのカラムに対応しているためActive Recordで簡単に取得・保存できる（リスト9.1）

ログインしたユーザーを記憶する処理の準備ができた
rememberヘルパーメソッドを追加してlog_inと連携させる
リスト9.7 ログインしてユーザーを保持する
log_inのときと同様、rememberメソッド定義のuser.rememberを呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストをDBに保存する

cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成する
リスト9.8 ユーザーを記憶する
ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されるが、リスト8.16のcurrnet_userメソッドでは一時セッションしか扱ってないのでこのままでは動作しない
@currnet_user ||= User.find_by(id: session[:user_id])

永続セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]からユーザーを取り出して対応する永続セッションにログインする必要がある
次のように記述する
if session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
elsif cookies.encrypted[:user_id]
  user = User.find_by(id: cookies.encrypted[:user_id])
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

user && user.authenticatedのコードがsessionメソッドもcookiesメソッドもそれぞれ2回ずつ使われてしまい無駄
解消するためローカル変数を使う
if (user_id = session[:user_id])
  @current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.encrypted[:user_id])
  user = User.find_by(id: user_id)
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

if (user_id = session[:user_id])
このコードは比較のようで比較でなく、代入
「ユーザーIDがユーザーIDのセッションと等しければ」ではなく「（ユーザーIDにユーザーIDのセッションを代入した結果）ユーザーIDのセッションが存在すれば」となる

currnet_userを定義
リスト9.9 永続的セッションのcurrent_userを更新

現在の設計ではユーザーが複数の端末からログインすることを想定していないので、２つ以上のブラウザでremember me機能を使えない
解決策は、記憶ダイジェストを１個のテーブルとして新たに作成し、そのテーブルをユーザーのIDと紐付ける
例えば現在のユーザーを見つけるときはそのテーブルを通して記憶ダイジェストと対応する記憶トークンをチェックするようにする
リスト9.11のforgotメソッドも同様に変更し、現在使っているブラウザに対応する記憶ダイジェストのみを削除させる必要がある
セキュリティを考慮してユーザーがログアウトした場合はそのユーザーに紐付いている全てのダイジェストを削除しておく

残された課題はcookiesを削除すること

9.1.3 ユーザーを忘れる

















9.1.2 ログイン状態の保持
ユーザーの暗号化済みIDと記憶トークンを永続cookiesに保存するために`cookies`メソッドを使う
sessionのときと同様ハッシュとして扱える
cookiesは1つの`value`値と`expires`有効期限（省略可能）からなる
```ruby
cookies[:remember_token] = {value: remember_token, expires: 20.years.from_now.utc}
```
これで20年後に期限切れになるcookieが保存された
有効期限20年はよく使われていて専用メソッドの`permanent`がある
```ruby
cookies.permanent[:remember_token] = remember_token
```
これでも同じ意味になる

ユーザーIDをcookiesに保存するが、そのままだとcookiesの形式がバレるので署名付きcookieを使う。cookieがブラウザに保存される前に安全に暗号化するためのもの
`cookies.signed[:user_id] = user.id`
ユーザーIDも永続的に保存する必要があるので`permanent`メソッドを使う
`cookies.permanent.signed[:user_id] = user.id
`

渡されたトークンがユーザーの記憶ダイジェストと一致するか調べる
`BCrypt::Password.new(remember_digest) == remember_token`
bcryptでは＝＝演算子が再定義されている実際の比較をコードにすると
`BCrypt::Password.new(remember_digest).is_password?(remember_token)`
＝＝がis_password?に替わっている
リスト9.6 記憶トークンと記憶ダイジェストを比較する
このメソッド内のremember_tokenはローカル関数

リスト9.7 sessions_controller ログイン時にユーザーを保持するremember処理の追加
リスト9.8 sessions_helper ユーザーを記憶する
1.Userモデルに定義したrememberメソッドを使って記憶トークンと記憶ダイジェスト生成
2.cookiesに署名付きID保存
3.cookiesに1で生成した記憶トークン保存→永続cookies生成

リスト9.9 一時セッションにしか対応していないcurrent_userメソッド修正
user_idというローカル変数を使い使い重複減らす

現時点ではcookiesを削除する手段がないのでログアウトできない
（現状のログアウト処理では一時セッションを削除するだけで永続cookiesを消せない）

9.1.3 ユーザーを忘れる
user.rememberを取り消すためのuser.forgetメソッドを定義する
リスト9.11 Userモデルに記憶ダイジェストをnilにするforgetメソッド追加

次にsessions_helper内にUserモデルのforgetメソッドを呼び出す`forget`ヘルパーメソッドを追加し、さらに`log_out`ヘルパーメソッド内で呼び出す
1.Userモデルのforgetメソッドで記憶ダイジェストをnilにする
2.sessions_helperのforgetメソッドで永続cookiesのユーザーIDと記憶トークンを削除
3.一時セッションのユーザーID削除
4.current_userをnilにする

9.1.4 ２つの目立たないバグ
1つ目は複数のタブで開いているときに片方でログアウトするともう片方のタブでログアウトしようとするとエラーが起きる
log_outメソッドを使ってログアウトするとcurrent_userがnilになる。そうすると２つ目のタブでは削除するcookiesが見つからないためエラーが発生する

２つ目は複数のブラウザでログインしているときに片方でログアウトしてもう片方のブラウザを閉じてからもう一度開くとエラーが起きる
ブラウザ１でログアウトすると`user.forget`メソッドにより`remember_digest`がnilになる
ブラウザ２を閉じると一時セッションは消えるがcookiesは残り続けるため、ブラウザ２を再起動するとデータベースからユーザーを発見してしまう
`current_user`内で使用されるはずの`remember_digest`
がすでにブラウザ１のログアウト処理で削除されているためエラー発生

これらのバグをキャッチするテスト作成後、修正するコードを書く
リスト9.14 ログインのテスト時に2回ログアウトを再現
現状はcurrent_userがないため2回目のログアウト時にウェアー発生
パスするためにはログイン中に限りログアウトできれば良い
リスト9.16

2つ目のバグ修正はテストで異なるブラウザ環境の再現は難しいのでUserモデルの`remember_digest`に関してのテストにする
記憶ダイジェストがnilの時はfalseを返すようにする
リスト9.19 `return`でnilの場合は処理がそこで終了する

9.2 Remember me チェックボックス
ログイン画面にチェックした時だけ記憶するチェックボックス追加
リスト9.21
```html
<%= f.label :remember_me, class: "checkbox inline" do %>
  <%= f.check_box :remember_me %>
  <span>Remember me on this computer</span>
<% end %>
```
ラベルの内側にチェックボックスを配置することで文字をクリックしてもチェックできる

チェックしたときに記憶、そうでないときに記憶しない処理を三項演算子を用いて書く
リスト9.23
条件文 ? true処理 : false処理
paramsの数値は文字列として記録されているので条件文の1は''で囲わないとfalseになってしまう

9.3 Remember meのテスト
9.3.1 ボックスをテストする

Rubyでは1と0はどちらもtrueとして扱われるためチュートリアル筆者のように`params[:session][:remember_me] ? remember(user) : forget(user)`と書くと値が常にtrueになってしまうのでこういったものをキャッチできるテストを書く

今まではテスト内でpostメソッドとsessionハッシュを使っていたが面倒なので`log_in_as`ヘルパーメソッドを定義
リスト9.24
統合テストではsessionメソッドを使えないため`log_in_as`メソッドをActionDispatch::IntegrationTestとActiveSupport::TestCaseで2回別々に定義している
統合テストも単体テストも同じ名前なので気にせず呼び出せる

リスト９.２５ チェックボックスのテスト
演習
統合テストでは`assigns`メソッドを使うことでインスタンス変数にアクセスできる
コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使う
session_controller内のcreateアクション内のローカル変数に@をつけてインスタンス変数に変えてしまえば`assigns`メソッドで呼び出せる

9.3.2 Remember me をテストする
sessions_helper内の`current user`メソッドの分岐処理のテストが行われていない
試しに関係ない文字列を差し込むことでテストされているか調べる（テストされていれば引っかかるはず）
リスト９.３１　sessions_helperテストの作成
1つ目のテストでは記憶したユーザーとcurrent_userが同じかどうか検証した後にログインしているかも確認している
２つ目のテストはremember_digestを書き換えてrememberメソッドで記憶した記憶トークンと対応させないようにして、ユーザーの記憶ダイジェストが記憶トークンと対応していないときに現在のユーザーがnilになるかどうかを確認している→authenticated?が動いているかテストしている
`assert_equal`メソッドは第一引数に期待値、第二引数に実際の値を書く
逆でも動くが、エラーが発生したときにログの表示が噛み合わなくなる