セキュアなパスワード
ハッシュ化とは
- 何らかの演算を行うことによって、入力されたデータを元に戻せないデータに変換する処理
- こうした演算を行うための関数は「ハッシュ関数」と呼ばれる
- Rubyのデータ型であるハッシュとは直接関係ない
- 本節における「セキュアなパスワード」というのは、ハッシュ化技術を基盤として構成される
- パスワードをハッシュ化したものをRDBに保存する
- ハッシュ化されたパスワードを用いてユーザー認証を行う
全体の処理手順
- ユーザーはRailsアプリケーションにパスワードを送信する1
- Railsアプリケーションは送信されてきたパスワードをハッシュ化する
- 2.でハッシュ化された文字列とRDBMS内に格納されたパスワードを比較し、一致すればユーザー認証OK
仮にRDBMSの内容が漏洩したとしても、「ユーザーが送信する文字列」という意味でのパスワードの安全性は保たれます。一方で、ユーザーがRailsアプリケーションにパスワードを送出する段階においては、通信経路の安全性を何らかの形で担保しなければなりません。
ハッシュ化されたパスワード
has_secure_password
メソッド
Railsにおけるセキュアなパスワードの実装は、has_secure_password
宣言のみでほぼ完結してしまいます。has_secure_password
は、以下のような機能を含んでいます。
- セキュアにハッシュ化したパスワードを、データベース内の
password_digest
という属性に保存する -
password
とpassword_confirmation
という属性のペアに対するバリデーションを定義する -
authenticate
メソッド- 引数の文字列がパスワードと一致すれば、モデルオブジェクトを返す
- 引数の文字列とパスワードが一致しなければ、
false
を返す
has_secure_password
は、モデルオブジェクト内で以下のように呼び出します。
class User < ApplicationRecord
...略
has_secure_password
end
password_digest
属性
has_secure_password
メソッドを使用するためには、対象となるモデルオブジェクトにpassword_digest
という属性が定義されている必要があります。今回はUserモデルで用いるので、Userのデータモデルを以下のように変更します。
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
- 属性名…
生成されたマイグレーションの内容は以下の通りになります。
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を実行する」という操作ですね。
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
メソッドを追加しましょう。
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
属性を追加する」という処理ですね。
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文字以上であること
実際にテストを定義してみます。
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
オプションを使って最小文字数のバリデーションを実装します。
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で確認してみます。結果は以下の通りでした。
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
属性の値を与えてやることにより、user
をsave
することができるようになりました。RDBMS側の情報もきちんと更新されていますね。
-
セキュリティー - ログイン時のパスワード送信に関して|teratail等のやり取りを見るに、「クライアント側でハッシュ化する」というのはアンチパターンであるそうです。その理由については、「ユーザーがRailsアプリケーションに対して送信する文字列と、Railsアプリケーション側に保存されている文字列が異なるというのがポイントである」「ハッシュ化処理については、Railsアプリケーション側で脆弱にならないように手を打つものである」と解釈しました。 ↩
-
Railsチュートリアル本文では、このような演算を「多重代入」と言及していました。しかしながら、今回言及した特性は、Rubyの多重代入とはまた別の概念を指しています。 ↩