読者です 読者をやめる 読者になる 読者になる

techium

このブログは何かに追われないと頑張れない人たちが週一更新をノルマに技術情報を発信するブログです。もし何か調査して欲しい内容がありましたら、@kobashinG or @muchiki0226 までいただけますと気が向いたら調査するかもしれません。

Rails Tutorial 5 Advanced login

Rails Tutorial 5 Advanced login

永続クッキーを用いて、ブラウザ終了後もユーザのログイン情報を記憶しておく方法を学習する。

Remember me

  1. cookies にログイン情報を保存する
  2. パスワード自体ではなく、発行したトークンを保存
  3. トークンはハッシュ値に変換してからDBに保存
  4. cookiesに保存するユーザーIDは暗号化
  5. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索
  6. cookies に保存してあるトークンがデータベース内のハッシュ値と一致することを確認

確認できたらログイン済みとして扱うようにすれば、remember me 機能の完成。

  1. 署名されたユーザーIDだけを盗まれても、記憶トークンがなければ不正利用はできない
  2. 署名されたユーザーIDと記憶トークンをセットで盗まれてしまうとログインできてしまう
  3. 上記不正利用されている状態であっても、別のブラウザでユーザーがログアウト操作をすると不正利用者はログインできなくなる

記憶トークンを作成するのはこのため。

まず 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 にしておけば、デフォルトのメンテナンス画面を見せることができるそうな。

f:id:kfurue:20170326224527p:plain

まとめ

この章で、なんだか流して読んでいるだけでは内容が理解できなくなってしまった。
各項の操作が、データベースに対してなのか、cookies に対してなのかをしっかり区別できていなかったことが原因だったようだ。 データベース、cookies それぞれに対してどのような処理を行っているのかという流れに注意しながら読み進めれば問題ない。
(ハッシュ化とハッシュで地味に混同するのも見逃せない)