Rails Tutorial 5 Advanced login
Rails Tutorial 5 Advanced login
永続クッキーを用いて、ブラウザ終了後もユーザのログイン情報を記憶しておく方法を学習する。
Remember me
- cookies にログイン情報を保存する
- パスワード自体ではなく、発行したトークンを保存
- トークンはハッシュ値に変換してからDBに保存
- cookiesに保存するユーザーIDは暗号化
- 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索
- cookies に保存してあるトークンがデータベース内のハッシュ値と一致することを確認
確認できたらログイン済みとして扱うようにすれば、remember me 機能の完成。
- 署名されたユーザーIDだけを盗まれても、記憶トークンがなければ不正利用はできない
- 署名されたユーザーIDと記憶トークンをセットで盗まれてしまうとログインできてしまう
- 上記不正利用されている状態であっても、別のブラウザでユーザーがログアウト操作をすると不正利用者はログインできなくなる
記憶トークンを作成するのはこのため。
まず User モデルに remember_digest 属性を追加する。
$ rails generate migration add_remember_digest_to_users remember_digest:string Running via Spring preloader in process 1680 invoke active_record create db/migrate/20170206234706_add_remember_digest_to_users.rb
$ rails db:migrate == 20170206234706 AddRememberDigestToUsers: migrating ========================= -- add_column(:users, :remember_digest, :string) -> 0.0046s == 20170206234706 AddRememberDigestToUsers: migrated (0.0047s) ================
Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64が生成する文字列を記憶トークンとして使用する。 (実際ここはランダムな文字列ならなんでも良いが、著者はここを参考にこのメソッドを選んだそうな。)
ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存します。
ということで、new_tokenメソッドを新たに作成する。
app/models/user.rb
に以下を追記する。
# Returns a random token. def User.new_token SecureRandom.urlsafe_base64 end
次にuser.rememberメソッドを作成していく。 ユーザIDから、紐付いている記憶トークンを検索して一致することを確認する、という処理が必要になるので、まずは記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存する処理をこのメソッドに持たせる。
remember_digest属性は先ほど追加したが、remember_token属性はまだなのでcookiesの保存場所であるuser.remember_tokenを使用してトークンにアクセスできるようにする。
app/models/user.rb
class User < ApplicationRecord attr_accessor :remember_token . . . # Remembers a user in the database for use in persistent sessions. def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end end
以上で、 - 記憶トークンの発行 - 発行した記憶トークンのハッシュ化 - ハッシュ化した記憶トークンを DB に保存
ができる。
Login with remembering
続いて、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存する処理を cookies
メソッドを用いて実装していく。
cookies.permanent.signed[:user_id] = user.id
これだけで暗号化されたユーザーIDを永続化できるとのこと。 cookies からユーザーを取り出す際は以下のようにする。
User.find_by(id: cookies.signed[:user_id])
これであとは、cookies に保存してあるトークンがデータベース内のハッシュ値と一致することを確認する。
app/models/user.rb
# Returns true if the given token matches the digest. def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end # Forgets a user. def forget update_attribute(:remember_digest, nil) end
remember
メソッドと current_user
メソッドを以下のように作成・書き換える。
app/helpers/sessions_helper.rb
# Remembers a user in a persistent session. def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end # Returns the user corresponding to the remember token 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 ・ ・ ・ # Forgets a persistent session. def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end # Logs out the current user. def log_out forget(current_user) session.delete(:user_id) @current_user = nil end
現実装では、ユーザーが複数のタブやブラウザで同時にログインしている場合に片方でログアウトした後もう片方でログイン状態が復元できてしまったり、エラーが発生したりと言ったバグが残っているのでテスト駆動開発で修正していく。
まずはこれらのエラーをキャッチするテストを作成する。
test/integration/users_login_test.rb
# Simulate a user clicking logout in a second window. delete logout_path
test/models/user_test.rb
. . . test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?('') end
テストが失敗するようになったので、これが通るように実装を修正していく。
app/controllers/sessions_controller.rb
def destroy log_out if logged_in? redirect_to root_url end
app/models/user.rb
# Returns true if the given token matches the digest. def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end
これで先のテストが通るようになる。
あとは、永続cookiesに保存するかどうかの選択をユーザができるようにチェックボックスを実装する。
“Remember me” checkbox
フォームにチェックボックスを追加する。
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 %>
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; }
あとはチェックボックスがオンなら保存、オフなら削除と実装してあげれば完成。
app/controllers/sessions_controller.rb
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
最後にRemember me 周りのテストを追加する。
テストに必要なヘルパーを追加する。
test/test_helper.rb
を以下のように書き換える。
ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../../config/environment', __FILE__) require 'rails/test_help' require "minitest/reporters" Minitest::Reporters.use! class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all # Returns true if a test user is logged in. def is_logged_in? !session[:user_id].nil? end # Log in as a particular user. def log_in_as(user) session[:user_id] = user.id end end class ActionDispatch::IntegrationTest # Log in as a particular user. 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 to set the cookie. log_in_as(@user, remember_me: '1') # Log in again and verify that the cookie is deleted. log_in_as(@user, remember_me: '0') assert_empty cookies['remember_token'] end end
演習で、cookiesの値がユーザーの記憶トークンと一致することを確認できるように書き換えたりしているが、以降の章でも特に触れられていないので置いておく。
筆者はこのようなとき、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むというテクニックを使います。つまり、そのコードブロックがテストから漏れていれば、テストはパスしてしまうはずです。
なるほど、上記は rais
を仕込むことで実現できるらしい。積極的に使っていきたい
Deploying
$ heroku maintenance:on $ git push heroku $ heroku run rails db:migrate $ heroku maintenance:off
Heroku へのデプロイ前にメンテナンスモードを on にしておけば、デフォルトのメンテナンス画面を見せることができるそうな。
まとめ
この章で、なんだか流して読んでいるだけでは内容が理解できなくなってしまった。
各項の操作が、データベースに対してなのか、cookies に対してなのかをしっかり区別できていなかったことが原因だったようだ。
データベース、cookies それぞれに対してどのような処理を行っているのかという流れに注意しながら読み進めれば問題ない。
(ハッシュ化とハッシュで地味に混同するのも見逃せない)