0
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 5 years have passed since last update.

チュートリアル9章

Last updated at Posted at 2019-07-30

永続クッキー (permanent cookies) 機能実装

Remember me 機能
・記憶トークンと暗号化
cookiesを盗み出す有名な方法は4通りあります。
(1) 管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す。

(2) データベースから記憶トークンを取り出す。

(3) クロスサイトスクリプティング (XSS) を使う。

(4) ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。

最初の問題を防止するためにSecure Sockets Layer (SSL) をサイト全体に適用して、ネットワークデータを暗号化で保護し、パケットスニッファから読み取られないようにしています。

2番目の問題の対策としては、記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存するようにします。これは、6.3で生のパスワードをデータベースに保存する代わりに、パスワードのダイジェストを保存したのと同じ考え方に基づいています

3番目の問題については、Railsによって自動的に対策が行われます。具体的には、ビューのテンプレートで入力した内容をすべて自動的にエスケープします。

4番目のログイン中のコンピュータへの物理アクセスによる攻撃については、さすがにシステム側での根本的な防衛手段を講じることは不可能なのですが、二次被害を最小限に留めることは可能です。具体的には、ユーザーが (別端末などで) ログアウトしたときにトークンを必ず変更する

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

$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate

Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使う

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

仮想のpassword属性はhas_secure_passwordメソッドで自動的に作成されましたが、今回はremember_tokenのコードを自分で書く必要があります

app/models/user.rb
 class User < ApplicationRecord
  attr_accessor :remember_token ここ追加
  before_save { self.email = email.downcase }
.
.
.

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end

ログイン状態の保持
cookiesは、1つのvalue (値) と、オプションのexpires (有効期限) からできています

ユーザーIDと記憶トークンはペアで扱う必要がある
signedとpermanentをメソッドチェーンで繋いで使います

bcryptで暗号化されたパスワードを、トークンと直接比較しています。

app/models/user.rb




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

  def new
  end

  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

  def destroy
    log_out
    redirect_to root_url
  end
end
app/helpers/sessions_helper.rb
 module SessionsHelper

  



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



app/helpers/sessions_helper.rb
 module SessionsHelper





  # 記憶トークン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



ユーザーを忘れる

user.forgetメソッドによって、user.rememberが取り消されます。具体的には、記憶ダイジェストをnilで更新します

app/models/user.rb
.
.
.

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

forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す

app/helpers/sessions_helper.rb
 module SessionsHelper

  .
  .
  .
  # 永続的セッションを破棄する
  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
end

バグが2つ残っていて
・同じサイトを複数のタブ 開いて1つのタブでログアウトもう一つでもログアウト操作するとエラー(current_userがnil)になる=log_outメソッド内のforget(current_user)が失敗してしまう
の問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要があります。

・複数ブラウザを表示し一つの方でログアウト、もう一つの方は閉じて、再度同じページを開く。閉じた際にセッションは切れるがcookieが残るのでページを再度表示しようとする際に、

user && user.authenticated?(cookies[:remember_token])

という文で最初のブラウザでログアウトしたのでremember_digestが消えているのに
再度ページにアクセスしようとしたときに

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

この文が流れるので、ここでエラーが起きる
この問題を解決するには、remember_digestが存在しないときはfalseを返す処理をauthenticated?に追加
まずはテスト
1つめバグ

test/integration/users_login_test.rb

  .
  .
  .

    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_path ←ここ追加
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end
end
app/controllers/sessions_controller.rb

  .
  .
  .
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

2つめバグ
ダイジェストを持たないユーザーを用意しauthenticated?を呼び出します

test/models/user_test.rb

  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end
app/models/user.rb
 class User < ApplicationRecord
  .
  .
  .
  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    return false if remember_digest.nil? ←ここ追加
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

[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 %>

#この上を追加↑

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>
app/assets/stylesheets/custom.scss
 .
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

# session_remember_me {
  width: auto;
  margin-left: 0;
}
app/controllers/sessions_controller.rb
 class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user) ←ここ追加
      redirect_to user
    else

[Remember me] のテスト

cookiesがnilであるかどうかだけをチェックすればよい
テスト内ではcookiesメソッドにシンボルを使えないので文字列をキーに

test/test_helper.rb
 ENV['RAILS_ENV'] ||= 'test'
.
.
.
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

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
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
    # クッキーを保存してログイン
    log_in_as(@user, remember_me: '1')
    delete logout_path
    # クッキーを削除してログイン
    log_in_as(@user, remember_me: '0')
    assert_empty cookies['remember_token']
  end
end

[Remember me] をテストする
テスト手順はシンプルです。

・fixtureでuser変数を定義する
・渡されたユーザーをrememberメソッドで記憶する
・current_userが、渡されたユーザーと同じであることを確認します

$ touch test/helpers/sessions_helper_test.rb
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

assert_equalの引数は「期待する値, 実際の値」の順序で書く

pushして終わり。

0
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
0
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?