LoginSignup
3
0

More than 3 years have passed since last update.

Railsチュートリアル 第6章 ユーザーのモデルを作成する - セキュアなパスワードを追加する

Posted at

セキュアなパスワード

ハッシュ化とは

  • 何らかの演算を行うことによって、入力されたデータを元に戻せないデータに変換する処理
    • こうした演算を行うための関数は「ハッシュ関数」と呼ばれる
  • Rubyのデータ型であるハッシュとは直接関係ない
  • 本節における「セキュアなパスワード」というのは、ハッシュ化技術を基盤として構成される
    • パスワードをハッシュ化したものをRDBに保存する
    • ハッシュ化されたパスワードを用いてユーザー認証を行う

全体の処理手順

  1. ユーザーはRailsアプリケーションにパスワードを送信する1
  2. Railsアプリケーションは送信されてきたパスワードをハッシュ化する
  3. 2.でハッシュ化された文字列とRDBMS内に格納されたパスワードを比較し、一致すればユーザー認証OK

仮にRDBMSの内容が漏洩したとしても、「ユーザーが送信する文字列」という意味でのパスワードの安全性は保たれます。一方で、ユーザーがRailsアプリケーションにパスワードを送出する段階においては、通信経路の安全性を何らかの形で担保しなければなりません。

ハッシュ化されたパスワード

has_secure_passwordメソッド

Railsにおけるセキュアなパスワードの実装は、has_secure_password宣言のみでほぼ完結してしまいます。has_secure_passwordは、以下のような機能を含んでいます。

  • セキュアにハッシュ化したパスワードを、データベース内のpassword_digestという属性に保存する
  • passwordpassword_confirmationという属性のペアに対するバリデーションを定義する
  • authenticateメソッド
    • 引数の文字列がパスワードと一致すれば、モデルオブジェクトを返す
    • 引数の文字列とパスワードが一致しなければ、falseを返す

has_secure_passwordは、モデルオブジェクト内で以下のように呼び出します。

class User < ApplicationRecord
  ...
  has_secure_password
end

password_digest属性

has_secure_passwordメソッドを使用するためには、対象となるモデルオブジェクトにpassword_digestという属性が定義されている必要があります。今回はUserモデルで用いるので、Userのデータモデルを以下のように変更します。

User_full.png

password_digest属性を定義するために、Userモデルを更新する

password_digestカラムを用いるために、Userモデルを更新する必要が出てきました。今回Userモデルの更新に用いるマイグレーションを生成するためのコマンドは、以下の通りになります。

# rails generate migration add_password_digest_to_users password_digest:string
Running via Spring preloader in process 2628
      invoke  active_record
      create    db/migrate/[timestamp]_add_password_digest_to_users.rb

このコマンドのポイントは以下です。

  • マイグレーション名の末尾が_to_usersである
    • こうした名前にしておけば、usersテーブルにカラムを追加するマイグレーションが、Railsの機能によって自動で追加される
  • password_digest:stringは、属性名と型情報を表す
    • 属性名…password_digest
    • 型…string

生成されたマイグレーションの内容は以下の通りになります。

db/migrate/[timestamp]_add_password_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :password_digest, :string
  end
end

add_columnメソッドにより、usersテーブルにstring型のpassword_digestカラムを生成する処理です。

生成したマイグレーションをRDBMSに適用するコマンドは以下のとおりです。

# rails db:migrate  
== [timestamp] AddPasswordDigestToUsers: migrating =========================
-- add_column(:users, :password_digest, :string)
   -> 0.0211s
== [timestamp] AddPasswordDigestToUsers: migrated (0.0231s) ================

確かにadd_column(:users, :password_digest, :string)が実行されていますね。

必要なGemをインストールし、ハッシュ関数そのものを使用できるようにする

先にRDBMS側の準備を整えました。しかしながら、現時点ではではまだハッシュ関数を処理するために必要なGemがインストールされておらず、そのままではハッシュ関数を使うことができません。

今回は、ハッシュ関数を処理するためのGemとしてbcryptを用います。

必要となるのは、「Gemfileを更新し、bundleを実行する」という操作ですね。

/gemfile
  source 'https://rubygems.org'

  gem 'rails',        '5.1.6'
+ gem 'bcrypt',       '3.1.12'
  ...略

続いて、bundle installコマンドを実行します。

# bundle install
...略
Fetching bcrypt 3.1.12
Installing bcrypt 3.1.12 with native extensions
...略
Bundle complete! 23 Gemfile dependencies, 81 gems now installed.
Gems in the group production were not installed.
Bundled gems are installed into `/usr/local/bundle`

無事bcryptがインストールされたようです。

ユーザーがセキュアなパスワードを持っている

  • Userモデルにpassword_digest属性を追加する
  • Gemfileにbcryptを追加する

以上の操作により、Userモデル内でhas_secure_passwordが使えるようになりました。早速Userモデルにhas_secure_passwordメソッドを追加しましょう。

app/models/user.rb
  class User < ApplicationRecord
    before_save { email.downcase! }
    validates :name,  presence: true, length: { maximum: 50 }
    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
    validates :email, presence: true, length: { maximum: 255 },
                      format: { with: VALID_EMAIL_REGEX },
                      uniqueness: { case_sensitive: false }
+   has_secure_password
  end

has_secure_passwordを追加することにより、テストが通らなくなる

この時点では、テストが通りません。

# rails test:models
Started with run options --seed 60576

 FAIL["test_should_be_valid", UserTest, 0.06872039998415858]
 test_should_be_valid#UserTest (0.07s)
        Expected false to be truthy.
        test/models/user_test.rb:10:in `block in <class:UserTest>'

ERROR["test_email_addresses_should_be_saved_as_lower-case", UserTest, 0.09123980000731535]
 test_email_addresses_should_be_saved_as_lower-case#UserTest (0.09s)
ActiveRecord::RecordNotFound:         ActiveRecord::RecordNotFound: Couldn't find User without an ID
            test/models/user_test.rb:60:in `block in <class:UserTest>'

 FAIL["test_email_validation_should_be_accept_valid_addresses", UserTest, 0.13576069998089224]
 test_email_validation_should_be_accept_valid_addresses#UserTest (0.14s)
        "user@example.com" should be valid
        test/models/user_test.rb:37:in `block (2 levels) in <class:UserTest>'
        test/models/user_test.rb:35:in `each'
        test/models/user_test.rb:35:in `block in <class:UserTest>'

  9/9: [===================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.15226s
9 tests, 13 assertions, 2 failures, 1 errors, 0 skips

has_secure_passwordの追加とは直接関係しなさそうなところ(email_addresses_should_be_saved_as_lower-caseなど)でテストが通らなくなっている」ことに注意が必要です。何らかのライブラリ追加等を行った場合、このように原因がわかりづらい形でテストが通らなくなることがあります。

Railsチュートリアル本文によれば、テストが通らなくなった理由は以下のとおりです。

  • has_secure_passwordには、以下の仮想的な属性に対してバリデーションを行う機能が暗黙的に追加されている
    • password属性
    • password_confirmation属性
  • 一方で、現時点のテストデータでは、@user変数にpassword属性およびpassword_confirmation属性が定義されていない

というわけで、現時点でテストを通すために必要なのは「@user変数にpassword属性およびpassword_confirmation属性を追加する」という処理ですね。

test/models/user_test.rb
  class UserTest < ActiveSupport::TestCase

    def setup
-     @user = User.new(name: "Example User", email: "user@example.com")
+     @user = User.new(name: "Example User", email: "user@example.com",
+                     password: "foobar", password_confirmation: "foobar")
    end

  ...略
  end

これでテストが通るようになります。

# rails test:models
Started with run options --seed 17255

  9/9: [===================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.23941s
9 tests, 18 assertions, 0 failures, 0 errors, 0 skips

演習 - ハッシュ化されたパスワード

1. この時点では、userオブジェクトに有効な名前とメールアドレスを与えても、valid?で失敗してしまうことを確認してみてください。

Railsコンソールをサンドボックスモードで起動して確かめてみます。

# rails console --sandbox

>> u = User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil, password_digest: nil>

>> u.name = "foo"
=> "foo"

>> u.email = "foo@foobar.com"
=> "foo@foobar.com"

>> u.valid?
  User Exists (0.8ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ?  [["email", "foo@foobar.com"], ["LIMIT", 1]]
=> false

確かにvalid?falseを返していますね。

2. なぜ失敗してしまうのでしょうか? エラーメッセージを確認してみてください。

ユーザーオブジェクトのvalid?falseを返した時点で、副作用としてユーザーオブジェクトのerrorsオブジェクトにエラー情報が書き込まれています。エラーメッセージの内容を確認するためには、errors.full_messagesメソッドを使います。

>> u.errors.full_messages
=> ["Password can't be blank"]

「パスワードが空白であってはならない」という趣旨のエラーメッセージですね。

パスワードの最小文字数

パスワードの最小文字数が定義されていることのテストの実装

パスワードの最小文字数を強制するための機能の実装は、まず、パスワードの最小文字数が定義されていることのテストの実装から始まります。今回は、パスワードの最小文字数について、以下のルールを設定することとします。

  • パスワードが空でないこと
  • パスワードの最小文字数が6文字以上であること

実際にテストを定義してみます。

test/models/user_test.rb
  require 'test_helper'

  class UserTest < ActiveSupport::TestCase

    def setup
      @user = User.new(name: "Example User", email: "user@example.com",
                      password: "foobar", password_confirmation: "foobar")
    end

  ...略
+
+   test "password should be present (nonblank)" do
+     @user.password = @user.password_confirmation = " " * 6
+     assert_not @user.valid?
+   end
+
+   test "password should have a minimum length" do
+     @user.password = @user.password_confirmation = "a" * 5
+     assert_not @user.valid?
+   end
  end

Rubyにおける代入は、繋げてまとめて行うことができる

今回追加したコードの中で、Rubyの文法上注目すべきポイントは「代入は、a = b = c = 1のように繋げてまとめて行うことができる2」という点です。「代入演算子の評価は、右から左に向かって行われる」「代入式の値は、代入された値そのものになる」という仕様により、このような連鎖的な代入が可能となっています。

@user.password = @user.password_confirmation = "a" * 5

上述コードでは、パスワードとパスワード確認に対し、同時に代入を行っています。文字列の乗算を使った5文字の文字列の代入ですね。

モデルオブジェクトのvalidationにおけるminimumオプション

Name属性やEmail属性のバリデーションで、最大文字数を制約するmaximumというオプションを使いました。これに対し、最小文字数を制約するminimumというオプションも存在します。今回は、minimumオプションを使って最小文字数のバリデーションを実装します。

app/models/user.rb
  class User < ApplicationRecord
    before_save { email.downcase! }
    validates :name,  presence: true, length: { maximum: 50 }
    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
    validates :email, presence: true, length: { maximum: 255 },
                      format: { with: VALID_EMAIL_REGEX },
                      uniqueness: { case_sensitive: false }
    has_secure_password
+   validates :password, presence: true, length: { minimum: 6 }
  end

この実装はまた、「空のパスワードを許容しない」という制約を実装するために、presence: trueというオプションも追加しています。has_secure_passwordは、新しくレコードが追加された時点でのみ適用されるため、ユーザーが後から' '(6文字分の半角スペース)といった文字列でパスワードを書き換えることを拒絶できません。なので、presence: trueが別途必要になるわけなのです。

テストが通ることの確認

この時点でテストは通ります。

# rails test:models
Started with run options --seed 22788

  11/11: [=================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.30158s
11 tests, 20 assertions, 0 failures, 0 errors, 0 skips

先程「9 tests, 18 assertions」だったのが、今回は「11 tests, 20 assertions」に増えています。

演習 - パスワードの最小文字数

1. 有効な名前とメールアドレスでも、パスワードが短すぎるとuserオブジェクトが有効にならないことを確認してみましょう。

>> u = User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil, password_digest: nil>
>> u.name = "foo"
=> "foo"
>> u.email = "foo@foobar.com"
=> "foo@foobar.com"
>> u.password = "abc12"
=> "abc12"
>> u.valid?
  User Exists (0.3ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ?  [["email", "foo@foobar.com"], ["LIMIT", 1]]
=> false

password_digest属性の値に直接代入しない」ということが一つのポイントです。has_secure_passwordによって定義された仮想的な属性であるpassword属性に代入するのが正しい扱いです。

結果として、確かにusersオブジェクトのvalid?falseを返しています。

2. 上で失敗した時、どんなエラーメッセージになるでしょうか? 確認してみましょう。

>> u.errors.full_messages
=> ["Password is too short (minimum is 6 characters)"]

"Password is too short (minimum is 6 characters)"というメッセージが返ってきています。

なお、ここで"Password can't be blank"というメッセージが返ってくる場合、おそらく「password_digest属性に直接代入する」という処理をしてしまっていると思います。正しくはpassword属性です。

ユーザーの作成と認証

Railsコンソールを使って、データベースに新規ユーザーを作成する

ここまでの手順で、Userモデルの基本部分が完成しました。今度は実際にデータベースに新規ユーザーを1人作成してみます。

現在のところ、ユーザー登録に用いるWebフォームはまだ作られていません。そのため、「データベースにユーザーを追加する」という操作は、「Railsコンソールからユーザーを手動で作成する」という手法で実現することとします。

今回は、「実際にRDBMSに変更を反映する必要がある」という条件です。そのため、Railsコンソールはサンドボックスモードではない状態で起動する必要があります。

# rails console

>> User.create(name: "Michael Hartl", email: "mhartl@example.com",
?> password: "foobar", password_confirmation: "foobar")
   (0.1ms)  begin transaction
  User Exists (2.5ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ?  [["email", "mhartl@example.com"], ["LIMIT", 1]]
  SQL (11.5ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest") VALUES (?, ?, ?, ?, ?)  [["name", "Michael Hartl"], ["email", "mhartl@example.com"], ["created_at", "2019-10-13 06:43:53.935111"], ["updated_at", "2019-10-13 06:43:53.935111"], ["password_digest", "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN56IOU/ibHei"]]
   (9.4ms)  commit transaction
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 06:43:53", password_digest: "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN5...">

実際にユーザー情報が作成されたことを確認する

実際にユーザー情報が開発環境のRDBMSに保存されたことを確かめるために、Visual Studio CodeのSQLite Explorerで確認してみます。結果は以下の通りでした。

スクリーンショット 2019-10-13 15.45.16.png

id, name, email, created_at, updated_at, password_digest。ここまでに定義したUserモデルの属性に対応するカラムがきちんと存在しています。

再びRailsコンソールで、今しがた生成したuserオブジェクトのpassword_digest属性を参照してみましょう。

>> user = User.find_by(email: "mhartl@example.com")
  User Load (3.5ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "mhartl@example.com"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 06:43:53", password_digest: "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN5...">
>> user.password_digest
=> "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN56IOU/ibHei"

user.password_digest属性の値として格納されているのは、"foobar"という文字列をbcryptでハッシュ化した結果の文字列です。

authenticateメソッドを使ってみる

先ほど、Userモデルにhas_secure_passwordを追加しました。そのため、Userモデルのオブジェクトでauthenticateメソッドが使えるようになっています。このメソッドは、先ほども説明したとおり、「引数として与えた文字列をハッシュ化した値と、データベース内にあるpassword_digestの値を比較する」というメソッドです。

>> user.authenticate("not_the_right_password")
=> false

>> user.authenticate("foobaz")
=> false

以上は、authenticateメソッドに間違ったパスワードを与えた結果です。戻り値はfalseになっています。

user.authenticate("foobar")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 06:43:53", password_digest: "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN5...">

以上は、authenticateメソッドに正しいパスワードを与えた結果です。今度は、authenticateメソッドが当該ユーザーオブジェクト自身を返しています。

Rubyにおいて、falseでもnilでもないすべてのオブジェクトは、真理値として評価するとtrueとなります。当然ながらユーザーオブジェクトも真理値はtrueとなります。

>> !!user.authenticate("foobar")
=> true

演習 - ユーザーの作成と認証

1. コンソールを一度再起動して (userオブジェクトを消去して)、このセクションで作ったuserオブジェクトを検索してみてください。

>> exit

# rails console --sandbox

>> User.find_by(email: "mhartl@example.com")
  User Load (1.4ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "mhartl@example.com"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 06:43:53", password_digest: "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN5...">

2. オブジェクトが検索できたら、名前を新しい文字列に置き換え、saveメソッドで更新してみてください。うまくいきませんね...、なぜうまくいかなかったのでしょうか?

その1

>> (User.find_by(email: "mhartl@example.com").name = "Reimu Hakurei").save
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "mhartl@example.com"], ["LIMIT", 1]]
Traceback (most recent call last):
        1: from (irb):3
NoMethodError (undefined method `save' for "Reimu Hakurei":String)

「このようなやり方では、ユーザーオブジェクトを更新することはできない」という意味であれば、「代入式の戻り値は、代入した値そのものになる」というのが理由です。String型に対してsaveメソッドは使えないですよね。

その2

>> user = User.find_by(email: "mhartl@example.com")
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "mhartl@example.com"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 06:43:53", password_digest: "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN5...">

>> user.name = "Reimu Hakurei"
=> "Reimu Hakurei"

>> user.save
   (1.3ms)  SAVEPOINT active_record_1
  User Exists (0.2ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND ("users"."id" != ?) LIMIT ?  [["email", "mhartl@example.com"], ["id", 1], ["LIMIT", 1]]
   (0.1ms)  ROLLBACK TO SAVEPOINT active_record_1
=> false

>> user.errors.messages
=> {:password=>["can't be blank", "is too short (minimum is 6 characters)"]}

errors.messagesの内容を見るに、「仮想属性passwordの値が空であること」が原因のようです。

3. 今度は6.1.5で紹介したテクニックを使って、userの名前を更新してみてください。

>> user = User.find_by(email: "mhartl@example.com")
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "mhartl@example.com"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 06:43:53", password_digest: "$2a$10$rb26ZPyWkUh8T/cWML/OM.VhDiuJaii877zf3SvsfN5...">

>> user.name = "Reimu Hakurei"
=> "Reimu Hakurei"

>> user.password = "foobar"
=> "foobar"

>> user.save
   (0.2ms)  SAVEPOINT active_record_1
  User Exists (0.3ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND ("users"."id" != ?) LIMIT ?  [["email", "mhartl@example.com"], ["id", 1], ["LIMIT", 1]]
  SQL (10.5ms)  UPDATE "users" SET "name" = ?, "updated_at" = ?, "password_digest" = ? WHERE "users"."id" = ?  [["name", "Reimu Hakurei"], ["updated_at", "2019-10-13 07:17:16.160555"], ["password_digest", "$2a$10$wqQF.ZaFM.8HHI0mhpyPWe5Iv0fntWa33kHBjL9MDWeSdv2B/GuRi"], ["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

>> user.reload
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Reimu Hakurei", email: "mhartl@example.com", created_at: "2019-10-13 06:43:53", updated_at: "2019-10-13 07:17:16", password_digest: "$2a$10$wqQF.ZaFM.8HHI0mhpyPWe5Iv0fntWa33kHBjL9MDWe...">

user.password属性の値を与えてやることにより、usersaveすることができるようになりました。RDBMS側の情報もきちんと更新されていますね。


  1. セキュリティー - ログイン時のパスワード送信に関して|teratail等のやり取りを見るに、「クライアント側でハッシュ化する」というのはアンチパターンであるそうです。その理由については、「ユーザーがRailsアプリケーションに対して送信する文字列と、Railsアプリケーション側に保存されている文字列が異なるというのがポイントである」「ハッシュ化処理については、Railsアプリケーション側で脆弱にならないように手を打つものである」と解釈しました。 

  2. Railsチュートリアル本文では、このような演算を「多重代入」と言及していました。しかしながら、今回言及した特性は、Rubyの多重代入とはまた別の概念を指しています。 

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