#この章でやること
- ユーザー登録ページを作る
- そのためにユーザーモデル(DBテーブルと理解)を作成し、ユーザーデータを保存する
###コラム 6.1. 自分で認証システムを作ってみる
- すべてのWebアプリケーションは何らかのログイン/認証システムを必要とする
- そのため、多くのWebフレームワークではログイン/認証システムを実装するための選択肢が多数提供されている
- 特にDeviseというgemはプロ水準のアプリケーション向けの実績のあるソリューションとして広まっている。
- railsチュートリアルではこのようなソリューションは使わず、一旦自力で作ってみる
###6.1 Userモデル
- Railsでは、データモデルとして扱うデータ構造のことをモデル(Model)と呼ぶ
- Railsでは、データベースを使ってデータを長期間保存する
- データベースとやりとりをするRailsライブラリをActive Recordと呼ぶ
- Active Recordは、データオブジェクトの作成/保存/検索のためのメソッドを持っている
- Active Recordのメソッドを使うのに、SQL(Structured Query Language)の言語を気にしなくていい
- さらにRailsにはマイグレーション(Migration)という機能があり、データの定義をRubyで記述することができ、SQLのDDL(Data Definition Language)を新たに学ぶ必要がない
MySQLなどのコマンドを意識しなくてもいいということですね
###6.1.1 データベースの移行
- Railsはデータを保存する際にリレーショナルデータベースを使う
- リレーショナルデータベースは、データ行で構成されるテーブルからなり、各行はデータ属性のカラム(列)を持つ
こんな感じのテーブルを作る
id | name | |
---|---|---|
1 | sato | example.com |
2 | saito | example1.com |
3 | niikura | example2.com |
4 | inoue | example3.com |
####まずはUserモデルを生成する
$ rails generate model User name:string email:string
invoke active_record
create db/migrate/20160523010738_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
- コントローラ名には複数形を使い、モデル名には単数形を用いる
- name:stringやemail:stringオプションのパラメータを渡すことによって、データベースで使いたい2つの属性をRailsに伝える
- この結果マイグレーションと呼ばれる新しいファイルが生成され、データベースの構造をインクリメンタルに変更できるようになる
####生成されたUserモデルのマイグレーション
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
end
- マイグレーションファイル名の先頭には、それが生成された時間のタイムスタンプが追加 こんな感じの
20210213011517_create_users
- マイグレーション自体は、データベースに与える変更を定義したchangeメソッドの集まり
- 上記のchangeメソッドはcreate_tableというRailsのメソッドを呼び、ユーザーを保存するためのテーブルをデータベースに作成
- create_tableメソッドはブロック変数を1つ持つブロックを受け取り(※ここではt)、そのブロックの中でcreate_tableメソッドはtオブジェクトを使って、nameとemailカラムをデータベースに作る。
- モデル名は単数形(User)だがテーブル名は複数形(users)
- これはRailsで用いられる言葉の慣習でモデルはひとりのユーザーを表すのに対し、データベースのテーブルは複数のユーザーから構成されるため
- 最後の行t.timestampsは、created_atとupdated_atという2つの「マジックカラム(Magic Columns)」を作成
- マジックカラムはあるユーザーが作成または更新されたときに、その時刻を自動的に記録するタイムスタンプ
マイグレーションは、db:migrateコマンド を使って実行する
これを「マイグレーションの適用(migrating up)」と呼ぶ
$ rails db:migrate
初めてのマイグレーション適用後、db/development.sqlite3
という名前のファイルが生成される。
development.sqlite3
ファイルを開くためのDB Browser for SQLiteをDLして使うとデータベースの構造を見ることができる
※マイグレーションを間違えた場合、元に戻せる=ロールバックと呼ぶ
以下コマンドで可能に
$ rails db:rollback
###6.1.2 modelファイル
以降はrails generate model User name:string email:string
した後にできたファイルの解説
まずはUser.rbにみれる
class User < ApplicationRecord
end
-
class User < ApplicationRecord
という構文で、UserクラスはApplicationRecordを継承している - なのでUserモデルは自動的に
ActiveRecord::Base
クラスのすべての機能を持つ
ActiveRecord::Base
クラスがどんな機能を持っているかみていく
###6.1.3 ユーザーオブジェクトを作成する
rails console で調べていく
データベースを変更したくないので、コンソールをサンドボックスモードで起動。
$ rails console --sandbox
Loading development environment in sandbox
Any modifications you make will be rolled back on exit
(#ここで行ったすべての変更は終了時にロールバックされます)
>>
--sandboxというオプションをつけると、そのセッションで行ったデータベースへの変更をコンソールの終了時にすべて “ロールバック”(取り消し)してくれる
>> User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
# User.newを引数なしで呼んだ場合は、すべての属性がnilのオブジェクトを返す
>> user = User.new(name: "Michael Hartl", email: "michael@example.com")
=> #<User id: nil, name: "Michael Hartl", email: "michael@example.com",
created_at: nil, updated_at: nil>
####userオブジェクトが有効かどうかをvalid?メソッドを使って確認してみる
>> user.valid?
true
- 現時点ではまだデータベースにデータは格納されてない
- User.newはメモリ上でオブジェクトを作成しただけ
- user.valid?はただオブジェクトが有効かどうかを確認しただけ
- データベースにUserオブジェクトを保存するためには、userオブジェクトからsaveメソッドを呼び出す必要がある
>> user.save
(0.1ms) SAVEPOINT active_record_1
SQL (0.8ms) INSERT INTO "users" ("name", "email", "created_at",
"updated_at") VALUES (?, ?, ?, ?) [["name", "Michael Hartl"],
["email", "michael@example.com"], ["created_at", "2019-08-22 01:51:03.453035"],
["updated_at", "2019-08-22 01:51:03.453035"]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> true
user.saveとすることでデータベースに保存できる
次に作成した時点でのユーザーオブジェクトは、id属性、マジックカラムであるcreated_at属性とupdated_at属性の値がいずれもnilだったが、saveメソッドを実行した後に何が変更されたのかを確認する
>> user
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2019-08-22 01:51:03", updated_at: "2019-08-22 01:51:03">
- idには1という値が代入され、
- マジックカラムには現在の日時が代入されている
- 作成と更新のタイムスタンプは同一だが、更新するようになると値が異なる。
####Userモデルのインスタンスはドット記法を用いてその属性にアクセスできる
>> user.name
=> "Michael Hartl"
>> user.email
=> "mhartl@example.com"
>> user.updated_at
=> Mon, 23 May 2016 19:05:58 UTC +00:00
####今まではモデルを生成(new)と保存(save)別々で行ったが、createメソッドを使えば生成と保存を一緒にできる
※生成と保存は分けた方がいいのは間違いない
User.createは、trueかfalseを返す代わりに、ユーザーオブジェクト自身を返す。
返されたユーザーオブジェクトは(上の2つ目のコマンドにあるfooのように)変数に代入することもできます。
>> User.create(name: "A Nother", email: "another@example.org")
#<User id: 2, name: "A Nother", email: "another@example.org", created_at:
"2019-08-22 01:53:22", updated_at: "2019-08-22 01:53:22">
>> foo = User.create(name: "Foo", email: "foo@bar.com")
#<User id: 3, name: "Foo", email: "foo@bar.com", created_at: "2019-08-22
01:54:03", updated_at: "2019-08-22 01:54:03">
####destroyはcreateの逆
>> foo.destroy
(0.1ms) SAVEPOINT active_record_1
SQL (0.2ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 3]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 3, name: "Foo", email: "foo@bar.com", created_at: "2019-08-22
01:54:03", updated_at: "2019-08-22 01:54:03">
createと同じようにdestroyはそのオブジェクト自身を返しますが、その戻り値を使ってもう一度destroyを呼ぶことはできない
しかも削除されたオブジェクトはメモリ上に残る
>> foo
=> #<User id: 3, name: "Foo", email: "foo@bar.com", created_at: "2019-08-22
01:54:03", updated_at: "2019-08-22 01:54:03">
###6.1.4 ユーザーオブジェクトを検索する
ユーザーオブジェクトを検索する方法を学ぶ
findメソッドを使ってユーザーデータを探す
>> User.find(1) #idが1のユーザーを探して取り出す
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2019-08-22 01:51:03", updated_at: "2019-08-22 01:51:03">
#Active Recordはそのidのユーザーを返している
idが3のユーザーを探す
>> User.find(3)
ActiveRecord::RecordNotFound: Couldn't find User with ID=3
#destroyメソッドで3番目のユーザーを削除済だったため、Active Recordはこデータベースの中から見つけることができない
#代わりに、findメソッドは例外(exception)を発生させた
例外について
- 例外はプログラムの実行時に何か例外的なイベントが発生したことを示すために使われます。
###findメソッド以外にもユーザー検索できるメソッドがある
####find_by
>> User.find_by(email: "michael@example.com")
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2019-08-22 01:51:03", updated_at: "2019-08-22 01:51:03">
####firstメソッド
firstは単にデータベースの最初のユーザーを返す
>> User.first
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2019-08-22 01:51:03", updated_at: "2019-08-22 01:51:03">
####allメソッド
全てのオブジェクトを返す
>> User.all
=> #<ActiveRecord::Relation [#<User id: 1, name: "Michael Hartl", email:
"michael@example.com", created_at: "2019-08-22 01:51:03", updated_at:
"2019-08-22 01:51:03">, #<User id: 2, name: "A Nother", email:
"another@example.org", created_at: "2019-08-22 01:53:22", updated_at:
"2019-08-22 01:53:22">]>
###6.1.5 ユーザーオブジェクトを更新する
オブジェクトを更新するには既にあるオブジェクトに代入していく
>> user # userオブジェクトが持つ情報のおさらい
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 19:05:58", updated_at: "2016-05-23 19:05:58">
>> user.email = "mhartl@example.net"
=> "mhartl@example.net"
>> user.save #saveしなければ変更が取り消されてしまうので注意
=> true
#saveしなかった場合、取り消されてしまう
>> user.email
=> "mhartl@example.net"
>> user.email = "foo@bar.com" #違うメアドを代入
=> "foo@bar.com"
>> user.reload.email #reloadすると元に戻る
=> "mhartl@example.net"
#マジックカラムの更新日時も更新されている
>> user.created_at
=> Thu, 22 Aug 2019 01:51:03 UTC +00:00
>> user.updated_at
=> Thu, 22 Aug 2019 01:58:08 UTC +00:00
###updateを使ってもデータベースの更新できる
updateメソッドは属性のハッシュを受け取り、成功時には更新と保存を続けて同時に行う
検証に1つでも失敗すると、updateの呼び出しは失敗します
>> user.update(name: "The Dude", email: "dude@abides.org")
=> true
>> user.name
=> "The Dude"
>> user.email
=> "dude@abides.org"
####特定の属性のみを更新したい場合は、update_attributeを使う
update_attributeには、検証を回避するといった効果も
>> user.update_attribute(:name, "El Duderino")
=> true
>> user.name
=> "El Duderino"
##6.2 ユーザーを検証する
ここでやること
- 今のままではメールやパスワードは何の制限もなく、何でもOK状態なので、バリデーションを追加していく
- 例えば、メールアドレスは本当に存在しうるメールアドレスの形になっているのかとか、パスワードの長さを決めたりしていく
- その後、入力がおかしければエラーメッセージを表示する
- 具体的な検証項目は「空文字NG」パスワードの長さ(length)、フォーマット(format)「xxxx@xxx.xxx形式か」、他の人とメアド被ってないか、パスワードが入力されたパスか確認(confirmation)する
###6.2.1 有効性を検証する
テストを書きながらバリデーションが有効に働いているかチェックしていく
手順は以下の通り
- まず有効なモデルのオブジェクトを作成し、その属性のうちの1つを有効でない属性に意図的に変更
- バリデーションで失敗するかどうかをテスト
- 一応、バリデーションのテストが失敗したときにバリデーションの実装に問題があったのか、オブジェクトそのものに問題があったのかを確認できるようにするため、最初のモデルが有効であるかどうかもテストで確認する
$rails generate model User name:string email:string
で自動的にtestができているのでそれから見ていく
require 'test_helper'
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
####setup
メソッドとvalid?
メソッドを使って有効なUserかテストする
以下のコードの通りに
- setupメソッドを使って有効なUserオブジェクト(@user)を作成
- setupメソッド内に書かれた処理は、各テストが走る直前に実行される(@userインスタンスが作られてからテストできる)
- @userはインスタンス変数だが、setupメソッド内で宣言しておけば、すべてのテスト内でこのインスタンス変数が使えるようになる
- そうすると"should be valid"test内のvalid?メソッドを使ってUserオブジェクトの有効性をテストすることができる
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
test "should be valid" do
assert @user.valid? #assertは@user.valid?がtrueを返すと成功し、falseを返すと失敗します。
end
end
Userモデルにはまだバリデーションがないので、このテストは成功する
$ rails test:models
###6.2.2 存在性を検証する
単純にちゃんとnameとemailが入力されているかを確認する(空のままだと登録できないように)
まずはテストを書いて、空文字がNGになるか確認する
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
test "should be valid" do
assert @user.valid?
end
test "name should be present" do
@user.name = " " #空白の文字列をセット
assert_not @user.valid? #Userが有効でなくなったか確認
end
end
この時点では、モデルのテストは red
ここからuser.rbにバリデーションを書く
class User < ApplicationRecord
validates :name, presence: true #ここは()が実は省略されている validatesは要素を1つとる単なるメソッド
end
同じものがこちら ()を略してない
class User < ApplicationRecord
validates(:name, presence: true)
end
コンソールを起動して、バリが正しく作用しているか確認する
$ rails console --sandbox
>> user = User.new(name: "", email: "michael@example.com")
>> user.valid?
=> false
nameが空文字のためfalseになった
####errors.full_messagesとすることでなぜfalseになったのか調べられる
※messageのスペル注意(私間違えました massegesと)
>> user.errors.full_messages
=> ["Name can't be blank"]
Userオブジェクトは有効ではなくなったので、データベースに保存しようとすると自動的に失敗する
>> user.save
=> false
これで先ほど通らなかったテストはpassする
今はuser.nameに対してのテストを実行しパスしたので、次はemailのバリデーションのテストをパスにしていく
テストを書く
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
test "should be valid" do
assert @user.valid?
end
test "name should be present" do
@user.name = ""
assert_not @user.valid?
end
test "email should be present" do #追加
@user.email = " " #emailの空文字でuserインスタンスが生成されたら
assert_not @user.valid? #user.valid?がfalseならtestがパスする
end
end
class User < ApplicationRecord
validates :name, presence: true
validates :email, presence: true
end
これでメールも名前も空文字での入力はできなくなった
###6.2.3 長さを検証する
- ユーザー名の文字数を50を上限とする
- メールアドレスも255文字を上限とする
まずはテストを書く
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
.
.
.
test "name should not be too long" do
@user.name = "a" * 51 #@user.nameにaを51個代入 文字数オーバーさせる
assert_not @user.valid? #@userが有効でなければ成功
end
test "email should not be too long" do
@user.email = "a" * 244 + "@example.com" #@user.emailにaを244個+@...を代入 文字数オーバーさせる
assert_not @user.valid? ##@userが有効でなければ成功
end
end
この状態ではバリしてないので、テストは通らないので、バリを書いていく
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 } #length:{}で文字数指定
validates :email, presence: true, length: { maximum: 255 }
end
これでテストが green になる
###6.2.4 フォーマットを検証する
メアドをuser@example.comのような形で入力してもらうよう制限する
文字列の配列を簡単に作れる%w[]というテクニックを知っておく
>> %w[foo bar baz]
=> ["foo", "bar", "baz"] #空白で区切って配列に
>> addresses = %w[USER@foo.COM THE_US-ER@foo.bar.org first.last@foo.jp]
=> ["USER@foo.COM", "THE_US-ER@foo.bar.org", "first.last@foo.jp"]
>> addresses.each do |address|
?> puts address
>> end
USER@foo.COM
THE_US-ER@foo.bar.org
first.last@foo.jp
まずは、有効なメールアドレスだけがパスするかテストする
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
.
.
.
test "email validation should accept valid addresses" do
valid_addresses = %w[user@example.com USER@foo.COM A_US-ER@foo.bar.org
first.last@foo.jp alice+bob@baz.cn]
valid_addresses.each do |valid_address|
@user.email = valid_address
assert @user.valid?, "#{valid_address.inspect} should be valid"
#assertの第2引数にエラーメッセージを追加している→どのメールアドレスでテストが失敗したのかを特定できるようになる end
end
end
assert @user.valid?, "#{valid_address.inspect} should be valid"について
inspect
は要求されたオブジェクトを表現する文字列を返す
>> puts (1..5).to_a # 配列を文字列として出力
1 #改行されて並ぶ
2
3
4
5
>> puts (1..5).to_a.inspect # 配列のリテラルを出力
[1, 2, 3, 4, 5] #改行されずに並ぶ
>> puts :name, :name.inspect
name #シンボルをそのまま表示する
:name
>> puts "It worked!", "It worked!".inspect
It worked!
"It worked!" #""も含めて表示する
次は
- user@example,com(ドットではなくカンマになっている)
- user_at_foo.org(アットマーク ‘@’ がない)
- などの無効なメールアドレスを使って無効になることをテストする
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
.
.
.
test "email validation should reject invalid addresses" do
invalid_addresses = %w[user@example,com user_at_foo.org user.name@example. #変なメアドを代入
foo@bar_baz.com foo@bar+baz.com]
invalid_addresses.each do |invalid_address|
@user.email = invalid_address
assert_not @user.valid?, "#{invalid_address.inspect} should be invalid"
end
end
end
この時点では、テストは red
####formatオプションを使いメールフォーマットをチェックする
validates :email, format: { with: /<regular expression>/ }
formatオプションは「正規表現」をいれることでフォーマットを指定することができる
正規表現とは、こんな感じの文字列を使います
VALID_EMAIL_REGEX = /\A[\w+-.]+@[a-z\d-.]+.[a-z]+\z/i
意味不明ですよね。要は/とか[]とかを使うことで文字列の型を定義できるということです。
細かな内容を覚える必要はないと思いますが、こんな意味があるんだなと理解すればいいと思います。
正規表現 | 意味 |
---|---|
/\A[\w+-.]+@[a-z\d-.]+.[a-z]+\z/i | (完全な正規表現) |
/ | 正規表現の開始を示す |
\A | 文字列の先頭 |
[\w+-.]+ | 英数字、アンダースコア(_)、プラス(+)、ハイフン(-)、ドット(.)のいずれかを少なくとも1文字以上繰り返す |
@ | アットマーク |
[a-z\d-.]+ | 英小文字、数字、ハイフン、ドットのいずれかを少なくとも1文字以上繰り返す |
. | ドット |
[a-z]+ | 英小文字を少なくとも1文字以上繰り返す |
\z | 文字列の末尾 |
/ | 正規表現の終わりを示す |
i | 大文字小文字を無視するオプション |
####RubularというWebサイトで正規表現を試すことができる
https://rubular.com/
のサイトで試せる
####実際に正規表現でバリデートを記載する
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i #次で解説
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX }
end
正規表現VALID_EMAIL_REGEXは定数を指す=大文字で始まる名前はRubyでは定数を意味する
VALID_EMAIL_REGEX = /\A[\w+-.]+@[a-z\d-.]+.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX }
VALID_EMAIL_REGEX=で決めた正規表現パターンに一致するメールアドレスだけが有効であることをチェックできる
(..ドットが2つ続くのは正規表現でチェックできない)
###6.2.5 一意性を検証する
validatesメソッドの:uniquenessオプションを使い、メアドの一意性を検証する
※一意性はユニークの1つだけという意味です。同じアドレスが重複しないように
まずはテストを書く
今まではUser.newを書いてメモリ上でオブジェクトを作って検証をしていたが、(DBには登録してなかった)一意性の検証では、実際にDBへ登録しなければならない
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
.
.
.
test "email addresses should be unique" do
duplicate_user = @user.dup #user.dupは同じ属性を持つデータを複製する→setupでセットしたユーザーを再度生成
@user.save #@userをデータを保存する(複製する前のオリジン)
assert_not duplicate_user.valid? #複製したデータの有効性を検証し、falseなら成功する
end
end
.dup
は複製するメソッドです。コメントを参照すると何のテストを書いてるかわかるかと思います。
一意性のバリデーションを書いてないので、テストはパスしません。
emailのバリデーションにuniqueness: trueというオプションを追加
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: true #emailに一意性追加
end
通常、メールアドレスの文字列は大文字小文字の区別がないため、どちらの場合も検証しなければならない。
つまり、テストに同一メールアドレス文字が大文字の場合の検証を追記する必要がある。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
.
.
.
test "email addresses should be unique" do
duplicate_user = @user.dup
duplicate_user.email = @user.email.upcase #upcaseは大文字にするメソッド
@user.save
assert_not duplicate_user.valid?
end
end
rails consoleで確認
$ rails console --sandbox
>> user = User.create(name: "Example User", email: "user@example.com")
>> user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user = user.dup
>> duplicate_user.email = user.email.upcase
>> duplicate_user.valid? #大文字のメアドは有効?
=> true
今のままでは、大文字小文字で区別はされているが同じメールアドレスが複製可能となっている
つまり、`duplicate_user.valid?`ではfalseになる必要がある
ということで、:uniquenessに:case_sensitiveオプションを渡してみる。
```user.rb
uniqueness: { case_sensitive: false } #大文字小文字を区別しない(false)に設定する
ここで重要なのは、このオプションには一意性の検証も含まれる。
つまり、追記でuniqueness: trueとする必要がない
これでテストはパスする
まだ1つ問題が残っている
Active Recordはデータベースのレベルでは一意性を保証していないという問題
具体的にどういうことか、というと、このままビルドしてアプリケーションをローンチしてしまうと、
ユーザーが会員登録する場合、登録申請ボタンを素早く2回クリックした場合、リクエストが二つ連続で送信され、同一のメールアドレスを持つユーザーレコードが(一意性の検証を行なっているにも関わらず)作成されてしまう。
RailsのWebサイトでは、トラフィックが多い時にこのような問題が発生する模様。
実はこの問題も簡単に解決できる模様。
その方法は、データベースレベルでも一意性を強制すること
具体的には、データベース上のemailのカラムにインデックス(index)を追加し、そのインデックスが一意であるようにすれば解決する
###コラム 6.2. データベースのインデックス
また、データベース上のユーザー探索で、findメソッドを使って探す場合、今のままでは先頭から順にユーザーを一人ずつ探していくことになる。そうなると、例えばユーザーが1000人登録されている場合、1000番目のユーザーを探し当てるには1000回もサーチしなければならない。
これはデータベースの世界では全表スキャン(Full-table Scan)として知られており、
DBへの負担やリクエストの回数など考えても極めてよくない。
なので、その点からもemailのカラムにインデックスを追加すれば、割り当てたインデックスを管理する表の中から探し当てればいい訳で、アルゴリズムの計算量が比較的少なくて済む。
emailインデックスを追加するにはデータモデリングの変更が必要
今回の場合は、既に存在するモデルに構造を追加するので、次のようにmigrationジェネレーターを使ってマイグレーションを直接作成する
$ rails generate migration add_index_to_users_email
生成されたファイルに、add_indexというメソッドを使ってusersテーブルのemailカラムに、一意性を強制する為のunique: trueオプションを渡す。
class AddIndexToUsersEmail < ActiveRecord::Migration[6.0]
def change
add_index :users, :email, unique: true
end
end
データベースをマイグレート
$ rails db:migrate
fixturesファイルが一意性を保ってないので、削除する
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/
# FixtureSet.html
one:
name: MyString #ALL delete
email: MyString
two:
name: MyString
email: MyString
まだやることはあります、
それは、いくつかのDBアダプタが、常に大文字小文字を区別するインデックスを使っているとは限らないから。
例えばFoo@ExAMPle.Comとfoo@example.comが別々の文字列と解釈してしまうケースがある。
これらの文字列は同一であると解釈されるべきなので、
今回はデータベースに保存される直前に全ての文字列を小文字に変換する
これを実装するために、Active Recordのcallbackメソッドを利用する。
これはある特定の時点で呼び出されるメソッド。今回はオブジェクトが保存される前に実行したいため
before_save
というコールバックを使う。
before_save
を使ってデータベースに保存する前にemail属性を強制的に小文字に変換する
class User < ApplicationRecord
before_save { self.email = email.downcase } #自分自身のemailを小文字に
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: true #trueに戻す
#メールアドレスが小文字で統一されれば、大文字小文字を区別するマッチが問題なく動作できるから
end
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
.
.
.
test "email addresses should be unique" do
duplicate_user = @user.dup
@user.save
assert_not duplicate_user.valid?
end
end
- データベースは、最初のリクエストに基づいてユーザーのレコードを保存
- 2度目の保存は一意性の制約に反するので拒否
- インデックスをemail属性に追加したことで、全表スキャンを使わずに済むようになった
6.3 セキュアなパスワードを追加する
- ユーザーのパスワードを設定する
- パスワードは漏れたらヤバイのでハッシュ化して保存できるようにする
- ハッシュ化は、"12345678"というパスワードを"fdasjkfhaknflaflacjfa32454"こんな感じの文字列にすることで
- 見えなくし、このハッシュ化された文字列は元の"12345678"のような文字列に戻せない不可逆性を持たせます
- ログイン時は"ハッシュ化された文字列"(userテーブルに保存された文字列)と"ハッシュ化された文字列"(ログイン入力時に入力したパスワードをハッシュ化したもの)を比べて一致すればパスするという仕組みにする
###6.3.1 ハッシュ化されたパスワード
セキュアなパスワードの実装は、has_secure_password
というRailsのメソッドを呼び出すだけで終わる
このメソッドは、Userモデルで次のように呼び出せる。
class User < ApplicationRecord
.
.
.
has_secure_password
end
上のようにモデルにhas_secure_password
メソッドを追加すると、次のような機能が使えるようになります。
- セキュアにハッシュ化したパスワードを、データベース内のpassword_digestという属性に保存できるようになる。
- 2つのペアの仮想的な属性(passwordとpassword_confirmation)が使えるようになる。
- 存在性と値が一致するかどうかのバリデーションも追加される
- authenticateメソッドが使えるようになる(引数の文字列がパスワードと一致するとUserオブジェクトを、間違っているとfalseを返すメソッド)。
has_secure_password
機能を使えるようにするには、
モデル内にpassword_digestという属性のカラムを追加する必要がある
なのでまずは、Userモデルにpassword_digest属性を追加する
末尾をmigrationファイルの名前をto_usersにしておくことで、usersテーブルにカラムを追加するマイグレーションがRailsによって自動的に作成される
$ rails generate migration add_password_digest_to_users password_digest:string
できたマイグレーションファイル
class AddPasswordDigestToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :password_digest, :string
end
end
migrateを実行
$ rails db:migrate
加えてhas_secure_password
を使ってパスワードをハッシュ化するためには、bcrypt
が必要になる。
パスワードを適切にハッシュ化することで、たとえ攻撃者によってデータベースからパスワードが漏れてしまった場合でも、
Webサイトにログインされないようにできる
bcryptを使うために、bcrypt gemをGemfileに追加する
source 'https://rubygems.org'
gem 'rails', '6.0.3'
gem 'bcrypt', '3.1.13'
gem 'bootstrap-sass', '3.4.1'
.
.
.
bundle installを実行し
$ bundle install
###6.3.2 ユーザーがセキュアなパスワードを持っている
Userモデルにhas_secure_passwordを追加する
class User < ApplicationRecord
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: true
has_secure_password
end
今時点でtestを行うと失敗する
テストが失敗する理由は、has_secure_passwordには、仮想的なpassword属性とpassword_confirmation属性に対してバリデーションをする機能も(強制的に)追加されているから
しかしテストの@user 変数に password属性とpassword_confirmation値がセットされてないため追加する
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
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
.
.
.
end
これでtestがパスする
###6.3.3 パスワードの最小文字数
セキュリティの観点からパスワードの長さを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 #空文字が6個でおかしくなるかテスト
@user.password = @user.password_confirmation = " " * 6 #
assert_not @user.valid?
end
test "password should have a minimum length" do #5文字以下でおかしくなるかテスト
@user.password = @user.password_confirmation = "a" * 5
assert_not @user.valid?
end
end
ここで注目したいのは
@user.password = @user.password_confirmation = "a" * 5
の部分。=が二つ付いている、このような構文を多重代入(multiple Assignment)と呼ぶ。
上記の文では@user.passwordと@user.password_confirmationに"aaaaa"を代入している。
ちなみに、has_secure_passwordメソッドの存在性のバリデーションは、
新規にレコードが追加された時だけしか適用されず、例えばレコードのユーザー名を空白の文字で埋めて更新しようとすると、バリデーションが適用されずに更新されてしまう
この時点でテストはもちろん失敗。
テストをパスさせる為に、パスワードが空でないこと、最低6文字以上のバリデーションを掛ける。
validates :password, presence: true,length: { minimum: 6 } #passwordの文字列
class User < ApplicationRecord
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: true
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
end
テストはpassする
###6.3.4 ユーザーの作成と認証
今後のためにデータベースに新規ユーザーを1人作成する
has_secure_password
を追加して使えるようになったauthenticate
メソッドの効果もみていく
データベースに1人登録しようにもWebからはまだ登録できないので、Railsコンソールを使ってユーザーを手動で作成する
サンドボックス環境は使わないので、作成したユーザーを保存すると、データベースに反映される。
まずは有効な名前・メールアドレス・パスワード・パスワード確認を渡してユーザーを作成する
$ rails console
>> User.create(name: "Michael Hartl", email: "michael@example.com",
?> password: "foobar", password_confirmation: "foobar")
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2019-08-22 03:15:38", updated_at: "2019-08-22 03:15:38",
password_digest: [FILTERED]>
うまくデータベースに保存されたかどうかを確認するために、開発環境用のデータベースをDB Browser for SQLiteで開き、usersテーブルの中身を見てみる(割愛)
>> user = User.find_by(email: "michael@example.com")
>> user.password_digest
=> "$2a$12$WgjER5ovLFjC2hmCItmbTe6nAXzT3bO66GiAQ83Ev03eVp32zyNYG"
foobarという文字列がハッシュ化されているのが分かる。
これは、bcryptを使って生成しているので、この文字列から元々のパスワードを導出することはほぼ不可能。
さらに、has_secure_passwordをUserモデルに追加したことで、authenticateメソッドが使えるようになっている。
>> user.authenticate("not_the_right_password")
=> false
>> user.authenticate("foobaz")
=> false
>> user.authenticate("foobar")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-23 20:59:49", updated_at: "2018-12-23 20:59:49", password_digest: "$2a$10$W5uH1QYvbuqcARBxVnyDE.hVIHNuGMB4DW67hCv7Y/p...">
引数に間違ったパスワードを与えると、falseを返し、正しいパスワードを与えると、ユーザーオブジェクトを返していることが分かる。
後にauthenticateメソッドを使ってユーザーを認証しログイン機構を作る。
なお、authenticateは返す論理値はtrueであることは確認しておきたい。
>> !!user.authenticate("foobar")
=> true
この性質を利用すると、user.authenticateを活用できる。
あとはいつも通りにgitにcommitして終了