59,60日目
今日の学習
Ruby on Rails
Railsチュートリアル 第12章
この章ではパスワードの再設定を学んでいく。
第11章と内容が似通っているようなので、復習を兼ねられることも期待して進めていきたい。
全体の流れは、以下のとおり。
ユーザーがパスワードの再設定をリクエストし、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
該当メールアドレスがデータベースにある場合、再設定用トークンとそれに対応する再設定ダイジェストを生成
再設定用ダイジェストはデータベースに保存し、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込む
ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較し、トークンを認証
認証成功後、パスワード変更用のフォームをユーザーに表示
PasswordResetsリソース
アカウント有効化のときと同様に、パスワードをリセットする一連の流れをリソース化してモデリングをする。
再設定用のダイジェストなど、必要なデータはUserモデルに追加していく形にする。
有効化のときはedit
アクションのみを使用したが、今回はパスワード再設定フォームが必要なため、ビューのためのnew
アクションが必要になる。また、作成用、更新用のアクションも必要になる。
rails g controller PasswordResets new edit --no-test-framework
今回は統合テストでテストを行うため、個別のテストを生成しないオプションをつける。
今回の実装では、新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるため、new
、create
、edit
、update
のルーティングを用意する。
resources :password_resets, only: [:new, :create, :edit, :update]
また、パスワード再設定画面のリンクを、sessions/new.html.erb
に追加する。
新しいパスワードの設定
パスワード再設定のデータモデルは、前回activated
などのカラムを新しくUserモデルに追加した時と同様に設定する。
セキュリティ保持のために、トークンは必ずハッシュ化をして、ダイジェストを使うようにする。さらに、再設定用のリンクは短時間で期限切れになるようにする。
この二つの条件を実現するために、reset_digest:string
とreset_sent_at:datetime
のカラムをUserモデルに追加する。
rails g migration add_reset_to_users reset_digest:string reset_sent_at:datetime
新しいパスワード再設定の画面は、前回新しいセッションを作成するために用意したログインフォームを使用する。
form_with
で扱うリソースとURLが異なる点と、パスワード属性が省略されている違いがあるが、それ以外は使い回すことができる。
form_with
で@password_reset
ではなく:password_reset
とする。password_reset
というモデルは存在しないので、formの自動生成が行えない。
createアクションでパスワード再設定
ビューファイルのform_with
から送信を行った後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する。
送信後は、ルートURLにリダイレクトし、フラッシュを表示する。送信できなかった場合は、newページを出力してからflash.now
を使いその旨をユーザーに伝える。
app/controllers/password_resets_controller.rb
def create # 送信されたメールアドレスを探す @user = User.find_by(email: params[:password_reset][:email].downcase) # 見つかった場合、ダイジェストを生成しメールを送信 if @user @user.create_reset_digest @user.send_password_reset_email # フラッシュでメール送信したことを知らせる flash[:info] = "Email sent with password reset instructions" redirect_to root_url else # 見つからなかった場合、フラッシュで知らせる flash.now[:danger] = "Email address not found" render 'new' end end
次に、上で新しく使用したパスワード再設定用メソッドを2つ追加する。
app/models/user.rb
# :reset_tokenを追加 attr_accessor :remember_token, :activation_token, :reset_token #パスワード再設定の属性を設定 def create_reset_digest self.reset_token = User.new_token update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now) end def send_password_reset_email UserMailer.password_reset(self).deliver_now end
パスワード再設定メールとテンプレート
まず、前回Userメイラーを生成したときに作成しておいたpassword_reset
メソッドを使える状態にする。
app/mailers/user_mailer.rb
def password_reset(user) @user = user mail to: user.email, subject: "Password reset" end
また、ビューファイルにある再設定用のテンプレートをテキスト・HTML両方編集し、送信するメールの内容を書いていく。Railsチュートリアルにあるお手本通りの文章で作成した。
前回同様、Railsのメールプレビュー機能を利用して、パスワード再設定のメールのプレビューも行う。test/mailers/previews/user_mailer_preview.rb
有効なメールアドレス送信時にエラー
演習通り、ブラウザから有効なメールアドレスを送ってみるとエラーが発生した。
NoMethodError in PasswordResetsController#create undefined method `reset_digest=' for #<User:0x00007f190352fbc0> Did you mean? reset_token=
どうにもreset_digest
がうまく機能していないようで、dbファイルを確認してみたところ、うまくカラムの追加ができていないことが判明した。
コマンドで作成し、ちゃんとマイグレーションファイルが作成されているのを確認してからrails db:migrate
をした。
その後もう一度試してみると、有効なメールアドレスの送信に成功した。
Windows - Railsチュートリアル12章の頭でエラーが出て・・・。|teratail
こちらを参考にして解決することができた。
実際にパスワード再設定をしたUserオブジェクトのreset_digest
とreset_sent_at
の値を確認してみると、ちゃんと更新されている。
>> user.reset_digest => "$2a$12$.mh07lrPJw3KvIfJnrJN0uvlju7NGUlehd6P1y0ZkBDa1zxeIhkAe" >> user.reset_sent_at => Sun, 04 Jul 2021 14:53:40 UTC +00:00
パスワードを再設定するeditアクション
送信メールの生成ができたので、PasswordResets
コントローラのedit
アクションの実装を行う。
パスワードの再設定をするフォームのためのビューが必要になる。
その前に、パスワードを変更したいユーザーのメールアドレスを保持しておく必要がある。
変更時にメールアドレスをキーとしてユーザーを検索するため、メールアドレスが必要になるアクションはedit
、update
だが、フォームを一度送信してしまうとメールアドレスの情報が消えてしまう。
そのため、隠しフィールド
としてメールアドレスをページ内に保存する。
隠しフィールドは以下のように設定する。
<%= hidden_field_tag :email, @user.email %> #=> params[:email]に保存される
これをパスワード再設定のフォームに仕込んでおくことで、フォームの情報を送信する際にメールアドレスも送信されるようになる。
続いてedit
アクションを編集する。ユーザーが正当であるかどうかを調べるために、beforeフィルタを使用して@user
の情報が正しいかどうかを確認するようにする。
app/controllers/password_resets_controller.rb
before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] private def get_user @user = User.find_by(email: params[:email]) end def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end
これで、パスワードリセットの際に送られるメールのURLを開いたとき、パスワード再設定のフォームに飛べるようになった。
実際に再設定フォームから送信してみると、update
アクションがないですよとエラーが出る。次はこのアクションを設定していく。
パスワードを更新するupdateアクション
今回のupdate
アクションでは、次の四つのケースを考慮する。
- パスワード再設定の有効期限が切れていないか
- 無効なパスワードであれば失敗させ、理由も表示する
- 新しいパスワードが空文字でないか確認
- 新しいパスワードが正しければ更新
まずは1の、有効期限をチェックするメソッドをPrivate
メソッドとして作成する。
before_action :check_expiration, only: [:edit, :update] # 1.パスワード再設定の有効期限が切れていないかチェック def check_expiration if @user.password_reset_expired? flash[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end
問題は3で、以前allow_nil
というパスワードが空でも良いというオプションを実装したため、今回に限り空ではいけないように設定しなければならない。
パスワードの確認フィールドが空の場合はバリデーションで検出されるのだが、両方のフィールドが空の場合はバリデーションがスキップされてしまう。
Railsチュートリアルでは、@userオブジェクトにエラーメッセージを追加する方法で解決にあたっている。
具体的には、errors.add
というメソッドを使ってエラーメッセージを追加している。
@user.errors.add(:password, :blank)
こうすることで、パスワードが空だったときに空の文字列に対するデフォルトのメッセージを表示してくれる。
:blank
オプションをつけることで、rails-i18n
gemを使用している場合は、それぞれの言語における適切なメッセージを表示してくれる。
def update # 3.新しいパスワードが空文字になっていないかを確認 if params[:user][:password].empty? @user.errors.add(:password, :blank) render 'edit' # 4. 新しいパスワードが正しければ更新し、ログイン elsif @user.update(user_params) log_in @user # 再設定が成功したらダイジェストをnilにする @user.update_attribute(:reset_digest, nil) flash[:success] = "Password has been reset." redirect_to @user # 2.無効なパスワードであれば失敗させる else render 'edit' end end private def user_params # 入力されるのはパスワード関連のものだけ params.require(:user).permit(:password, :password_confirmation) end
今のままでは、password_reset_expired?
メソッドが動かないため、動く様にUserモデルで定義する。
今回は、2時間以上パスワードが再設定されなかった場合は期限切れにする処理を行う。Rubyでは、以下のように表現する。
reset_sent_at < 2.hours.ago
<
記号を、「〜より早い時刻」と読むと分かりやすい。「パスワード再設定メールの送信時刻(reset_sent_at)が、現在時刻より2時間以上早い場合」となる。
あとはUserモデルに、以下のようにすればよい。
def password_reset_expired? reset_sent_at < 2.hours.ago end
パスワード再設定の統合テスト
テストの冒頭で、パスワード再設定のフォームを表示する必要があるため、新しくテストを作成する。
Railsチュートリアルに従ってテストを行っただけなので割愛。
本番環境でパスワード再設定メールが送れない
実際にパスワード再設定ページからメールアドレス情報を送信すると、エラーが起きた。
heroku logs
コマンドで何が起きているのか見てみる。
ActiveModel::MissingAttributeError (can't write unknown attribute `reset_digest`):
とのこと。ご丁寧に問題箇所も添えてあった。
reset_digest
属性に書き込めませんよと言われているようだ。
マイグレーションファイルで誤字でもしたか?と思いマイグレーションファイルを確認する過程で気づく。Herokuの方でマイグレーションしていないと...。
そりゃ、存在しないカラムにデータを書き込めるわけがない。
heroku run rails db:migrate
を行うと、本番環境でもメールを送信することができた。ちゃんと指定したアドレス宛にリンクつきメールも届いている。
今日のやらかし
paramsをスペルミス
今日どころかしょっちゅうやっているが、params
という単語のスペルミス頻度が非常に高い。
今回はparmas
だった。エラーが出てようやく気づいた。ぱっと見でスペルミスがわかりづらい。
params
と入力する時、よく手がもつれそうになる。気をつけたい。
統合テストで@user
を定義し忘れ
パスワード再設定テストが何度もエラーになったが、原因はsetup
で@user
を定義する行を見落としていたからだった。