プログラミング備忘録

プログラミングの学習状況をメモしています

59,60日目

今日の学習

Ruby on Rails

Railsチュートリアル 第12章

railstutorial.jp

この章ではパスワードの再設定を学んでいく。

第11章と内容が似通っているようなので、復習を兼ねられることも期待して進めていきたい。

全体の流れは、以下のとおり。

  1. ユーザーがパスワードの再設定をリクエストし、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける

  2. 該当メールアドレスがデータベースにある場合、再設定用トークンとそれに対応する再設定ダイジェストを生成

  3. 再設定用ダイジェストはデータベースに保存し、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込む

  4. ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較し、トークンを認証

  5. 認証成功後、パスワード変更用のフォームをユーザーに表示

PasswordResetsリソース

アカウント有効化のときと同様に、パスワードをリセットする一連の流れをリソース化してモデリングをする。

再設定用のダイジェストなど、必要なデータはUserモデルに追加していく形にする。

有効化のときはeditアクションのみを使用したが、今回はパスワード再設定フォームが必要なため、ビューのためのnewアクションが必要になる。また、作成用、更新用のアクションも必要になる。

rails g controller PasswordResets new edit --no-test-framework

今回は統合テストでテストを行うため、個別のテストを生成しないオプションをつける。

今回の実装では、新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるため、newcreateeditupdateのルーティングを用意する。

resources :password_resets, only: [:new, :create, :edit, :update]

また、パスワード再設定画面のリンクを、sessions/new.html.erbに追加する。

新しいパスワードの設定

パスワード再設定のデータモデルは、前回activatedなどのカラムを新しくUserモデルに追加した時と同様に設定する。

セキュリティ保持のために、トークンは必ずハッシュ化をして、ダイジェストを使うようにする。さらに、再設定用のリンクは短時間で期限切れになるようにする。

この二つの条件を実現するために、reset_digest:stringreset_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_digestreset_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アクションの実装を行う。

パスワードの再設定をするフォームのためのビューが必要になる。

その前に、パスワードを変更したいユーザーのメールアドレスを保持しておく必要がある。

変更時にメールアドレスをキーとしてユーザーを検索するため、メールアドレスが必要になるアクションはeditupdateだが、フォームを一度送信してしまうとメールアドレスの情報が消えてしまう。

そのため、隠しフィールドとしてメールアドレスをページ内に保存する。

隠しフィールドは以下のように設定する。

<%= 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. パスワード再設定の有効期限が切れていないか
  2. 無効なパスワードであれば失敗させ、理由も表示する
  3. 新しいパスワードが空文字でないか確認
  4. 新しいパスワードが正しければ更新

まずは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-i18ngemを使用している場合は、それぞれの言語における適切なメッセージを表示してくれる。

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を定義する行を見落としていたからだった。