LoginSignup
1
0

More than 1 year has passed since last update.

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

Posted at

第9章

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

Remember me 機能

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

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

$ git checkout -b advanced-login

記憶トークンと暗号化

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

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

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としている。
マイグレーションが自動的に作られた。

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モデルのクラスメソッドとする。

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

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

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

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.digestself.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のソースコード
上記から
BCrypt::Password.new(password_digest) == unencrypted_password
これを参考にしたものを
BCrypt::Password.new(remember_digest) == remember_token
に置き換える。

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

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

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

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

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と連携させる。

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メソッド読み出すため、下のコードで定義する。

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]があればここからユーザーを取り出し、なければ対応する永続セッションにログインするといった形にする。
すると下のようなコードになる。

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で更新する。

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

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

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_usernilとなるため、forget(current_user)が失敗してしまうから。

②複数のブラウザでログインしてたとき、例えばFirefoxでログアウトし、Chromeではログアウトせずにブラウザを終了し、再度Chromeで同じページを開くと、この問題が発生する。
Firefoxからログアウトし、user.forgetメソッドによってremember_digestnilになる。一時セッションと永続クッキーがの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?に追加してあげる。

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

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を追記する。

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

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

これで一つ目の問題はOK

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

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

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

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チェックボックスにチェックがあったらログインを保持するというもの。

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

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クラスのcheckboxinlineは、Bootstrapにおいてチェックボックスとテキストとして同じ行に配置する。

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文で分岐させれば良いので

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に代入する。

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

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

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とする。

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::TestCaseActionDispatch::IntegrationTestの両方でlog_in_asメソッドを定義しているのは、単体テストか統合テストか意識せず、log_in_asメソッドを呼び出せるようにしたため。

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

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

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

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

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

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メソッドがきちんと動作しているか現時点で不明なのと、それに対するテストが書かれていない。

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が、渡されたユーザーと同じであるか確認

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_usernilであることを期待されているが、authenticated?メソッドの箇所を削除したため、userオブジェクトが返ってきてしまいテストが失敗している。

最後に

あとは、いつも通りGithubとHerokuにpushして終わり~。

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

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

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

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