永続クッキー (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メソッドを使う
.
.
.
# ランダムなトークンを返す
def User.new_token
SecureRandom.urlsafe_base64
end
end
仮想のpassword属性はhas_secure_passwordメソッドで自動的に作成されましたが、今回はremember_tokenのコードを自分で書く必要があります
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で暗号化されたパスワードを、トークンと直接比較しています。
・
・
・
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end
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
module SessionsHelper
・
・
・
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
・
・
・
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で更新します
.
.
.
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す
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つめバグ
.
.
.
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
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
2つめバグ
ダイジェストを持たないユーザーを用意しauthenticated?を呼び出します
.
.
.
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
end
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] チェックボックス
。
。
。
<%= 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>
.
.
.
/* forms */
.
.
.
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
# session_remember_me {
width: auto;
margin-left: 0;
}
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メソッドにシンボルを使えないので文字列をキーに
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 "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
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して終わり。