Railsチュートリアル第11章でアカウントの有効化の実装が完了し、ユーザーの本人確信ができるようになったので、次はパスワードを忘れた時のパスワードの再設定機能を実装していきたいと思います。
今回の記事で見ていく内容のほとんどは、アカウント有効化で見てきた内容と似通っていますが、用意すべきレイアウトの違いなど多少異なる点もあります。
その点も踏まえて、パスワード再設定の実装の流れを振り返っていきたいと思います。
開発環境
- Ruby 3.1.2
- Ruby on Rails 7.0.3
- M1 Macbook Air 2020
- mac OS Monterey (ver. 12.4)
- ターミナル bash (Rosetta 2 使用)
ソースコード
パスワード再設定の仕上がりイメージ
今回実装するパスワード再設定の、システム上の動作イメージは以下の通りです。
- ユーザーがパスワードの再設定をリクエストする
- ユーザーが送信したメールアドレスをキーにしてDBからユーザーを見つける
- 該当ユーザーのメールアドレスがDBにある場合は、再設定用トークン(reset_token)を生成する
- ③に対応する再設定ダイジェスト(reset_digest)を生成し、DBに保存する
- 再設定用トークン(reset_token)とユーザーのメールアドレスを含めた有効化リンクをメールに添付し、ユーザーに送信する
- ユーザーがメールを開き、有効化リンクをクリックする
- ⑥に該当するユーザーをDBから(メールアドレスをキーとして)検索し、保存された再設定用ダイジェスト(reset_digest)と再設定トークン(reset_token)を比較し認証する
- 認証に成功したら、パスワード変更用のフォームをユーザーに表示する
- ユーザーが任意のパスワードを入力し、更新ボタンでパスワードの再登録が完了する
- ちなみに、パスワード再登録の期限は2時間以内に設定(2時間を過ぎたら有効化リンクは無効となる)
トークンの生成、およびハッシュかしたトークン(ダイジェスト)のDBへの保存など、第11章のアカウント有効化と流れがよく似ていることがわかります。
とはいえ、アカウント有効化の場合は有効化リンクをクリックして認証に成功したらログインすれば良いだけですが、
今回のパスワード再設定の場合は、認証に成功したらパスワード変更用のフォームをユーザーに表示し、さらにパスワードの再登録までの処理を記述する必要があります。
実際に、以下のようなイメージでパスワードの再設定を行なっていきます。
まず、ユーザーはパスワード再設定用のフォームへ進みます。
次に、登録したメールアドレスを入力して送信ボタンを押します。
メールアドレスを登録していた場合は、そのメールアドレス宛てに有効化リンクを含んだメールが送信されます。
届いたメールを開いたら、パスワードの再設定用の有効化リンクをクリックしてパスワードの再設定に進みます。(今回はリンクの有効期限を2時間以内としています)
認証に成功したらパスワード再設定フォームが表示されるので、変更したい任意のパスワードを入力し、更新ボタンを押してパスワードの再設定を行います。
以上が、パスワード再設定の流れです。
パスワードの再設定を実装する具体的な手順
それでは、いよいよパスワードの再設定を実装していきましょう。
実装の大まかな流れは以下の通りです。
- PasswordResetsコントローラを生成する
- ハッシュ化したトークンを保存する再設定用ダイジェスト
reset_digest
、および再設定用リンクの送信日時reset_sent_at
を示すカラムを生成する - パスワード再設定リクエスト用のメールアドレス入力フォームを作成する
- パスワード再設定用メールのテンプレート(password_reset)を作成する
- パスワード再設定用のメソッド、およびcreateアクションを追加する
- パスワードの再設定フォームを作成する(editアクションで再設定)
- 再設定用のパスワードを更新する
こんな感じです。
今回のパスワードの再設定は、アカウント有効化を実装する手順とよく似ているため、基本的な説明は省略する形で進めていきます。
それでは、それぞれの項目について詳しく見ていきましょう。
①PasswordResetsコントローラを生成する
まず最初に、パスワード再設定用のPasswordResetsコントローラを作成していきます。
今回はビューも扱うので、new
アクションとedit
アクションも一緒に生成します。
$ rails g controller PasswordResets new edit
また今回の実装では、新しいパスワードを再設定するためのフォーム、およびUserモデル内のパスワードを変更するためのフォームが必要になるので、
config/routes.rb
にnew
、create
、edit
、update
のルーティングも用意しましょう。
Rails.application.routes.draw do
root "home#index"
get "about", to: "home#about"
get "signup", to: "users#new"
get "login", to: "sessions#new"
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
resources :users, except: [:new]
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
end
②ハッシュ化したトークンを保存する再設定用ダイジェストreset_digest
、および再設定用リンクの送信日時reset_sent_at
を示すカラムを生成する
続いては、パスワードの再設定に必要なカラムをUserモデルに追加していきます。
今回新たに追加するカラムは以下の2つです。
- 再設定用ダイジェスト…「reset_digest」
- 再設定用リンクの送信日時…「reset_sent_at」
追加するカラム名 | 型 |
---|---|
reset_digest | string |
reset_sent_at | datetime |
これらを、以下のコマンドを実行して追加します。
$ rails g migration add_reset_to_users reset_digest:string reset_sent_at:datetime
マイグレーションファイルが生成されるので、中身を開いて確認します。
class AddResetToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :reset_digest, :string
add_column :users, :reset_sent_at, :datetime
end
end
追加するカラム名などに間違いがなければ、マイグレーションを実行してカラムを追加します。
$ rails db:migrate
③パスワード再設定リクエスト用のメールアドレス入力フォームを作成する
まずは、ログインフォームからパスワード再設定用のページに飛べるよう、下記のようにリンクを貼ります。
・・・
<div class="fields">
<%= form.label "パスワード" %>
<div class="ui left icon input">
<i class="lock icon"></i>
<%= form.password_field :password, placeholder: "パスワード" %>
</div>
<!------------------ この行を追記 -------------------------------->
<%= link_to ">>パスワードをお忘れですか?", new_password_reset_path %>
<!-------------------------------------------------------------->
</div>
・・・
次に、パスワード再設定リクエスト用のメールアドレス入力フォームを作成します。
作成済みのログインフォームを参考に、以下のように作成します。
<div class="ui middle aligned grid mt15">
<div class="column">
<h2 class="ui teal image header">
<i class="user icon"></i>
<div class="content">
パスワードの再設定
</div>
</h2>
<%= form_with(scope: :password_reset, url: password_resets_path, local: true, class: "ui form segment") do |form| %>
<div class="field">
<%= form.label "メールアドレス" %>
<div class="ui left icon input">
<i class="envelope icon"></i>
<%= form.email_field :email, placeholder: "メールアドレス" %>
</div>
</div>
<div>
<%= form.submit "送信する", class: "ui teal submit button" %>
</div>
<% end %>
</div>
</div>
④メール送信用(パスワード再設定用)のテンプレート(password_reset)を作成する
パスワード再設定用のメール送信テンプレートを以下のコマンドで作成します。(Userメイラーにpassword_resetメソッドを生成)
$ rails g mailer UserMailer password_reset
続いて、Userメイラーuser_mailer.rb
にpassword_resetメソッドの内容を記述していきます(account_activationメソッドを参照)。
class UserMailer < ApplicationMailer
def account_activation(user)
@user = user
mail to: user.email, subject: "Account activation"
end
def password_reset(user)
@user = user
mail to: user.email, subject: "Password reset"
end
end
今回のパスワード再設定は、アカウント有効化の時と同様にDBからユーザーをメールアドレスで検索して再設定トークンを認証できるようにするために、リンクには再設定トークンとメールアドレスを両方含めておく必要があります。
(アカウントの有効化と手順は同じ)
ゆえに、パスワード再設定用の有効化リンクのパス(再設定トークンとメールアドレスを両方含んだURL)は以下のように表されます。
edit_account_activation_url(@user.reset_token, email: @user.email)
ここで、@userの再設定トークン、およびメールアドレスがそれぞれ以下の場合、
- 再設定トークン…
X9FkWNOAQru914FbR-rdAQ
- メールアドレス…
sample@example.com
パスワード再設定用リンクのURLは以下のように表されます。
http://localhost:3000/password_resets/X9FkWNOAQru914FbR-rdAQ/edit?email=sample%40example.com
ここで、%40
となっているのは、@
などの特殊記号はURLでは扱えないため、自動的にエスケープされたためです。
(後でparams[:email]でメールアドレスを読み込むときにエスケープは自動的に解除されます。)
それでは、上記の内容を踏まえてメールのテンプレートを作成していきましょう。
Userメイラー追加時にapp/views/user_mailer
ディレクトリ内に自動生成されたテンプレート(html用、text用)を以下のように編集します(文章は各自好きな文言を入れてください)。
<h1>User Authentication App</h1>
下記のリンクよりパスワードの再設定を行ってください。
</p>
<p>
<%= link_to "パスワードを再設定する", edit_password_reset_url(@user.reset_token, email: @user.email) %>
</p>
<p>このリンクの有効期限は2時間です。</p>
<p>パスワードを変更する場合は2時間以内に行ってください。</p>
下記のリンクよりパスワードの再設定を行ってください。
<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>
このリンクの有効期限は2時間です。
パスワードを変更する場合は2時間以内に行ってください。
テンプレートはこんな感じでOKです。
送信メールのプレビューしたい場合は、test/mailers/previews/user_mailer_preview.rb
に以下のように記述します。
class UserMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/account_activation
def account_activation
user = User.first
user.activation_token = User.new_token
UserMailer.account_activation(user)
end
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/password_reset
def password_reset
user = User.first
user.reset_token = User.new_token
UserMailer.password_reset(user)
end
end
続いて、コード中にコメントアウトされた状態で記載されている以下のURLをブラウザのアドレスバーに入力します。
http://localhost:3000/rails/mailers/user_mailer/password_reset
すると、送信メールのプレビューを表示することができるはずです。(railsサーバーが起動した状態で)
⑤パスワード再設定用のメソッド、およびcreateアクションを追加する
app/models/user.rb
に、パスワード再設定用のトークンを生成およびハッシュ化してDBに保存するメソッドと、再設定用のメールを送信するメソッドを記述します。
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
before_save :downcase_email
before_create :create_activation_digest
・・・
# パスワード再設定の属性を設定する
def create_reset_digest
self.reset_token = User.new_token
update_attribute(:reset_digest, User.digest(reset_token))
update_attribute(:reset_sent_at, Time.zone.now)
end
# パスワード再設定のメールを送信する
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
end
・・・
end
続いて、password_resetコントローラーのcreateアクションに、パスワード再設定リクエスト時の処理を記述します。
(再設定リクエスト用フォームにメールアドレスを入力して送信ボタンを押したときの処理)
class PasswordResetsController < ApplicationController
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:notice] = "パスワード再設定用のメールを送信しました。メールをご確認の上、パスワードの再設定を行ってください。"
redirect_to root_url
else
flash.now[:error] = "メールアドレスが存在しません"
render "new", status: :unprocessable_entity
end
end
def edit
end
end
上記のcreateアクションでは、
- 入力したメールアドレスに該当するユーザーがDBに存在するかどうかを確認
- ユーザーが存在したら再設定トークンを生成し、該当ユーザーのDBに再設定用ダイジェストを保存する
- 同時にパスワード再設定用の有効化リンクを含んだメールを送信する
- フラッシュメッセージ(成功)を表示し、ルートURLにリダイレクトする
- ユーザーが存在しない場合はフラッシュメッセージ(失敗)を表示し、再び入力フォームをレンダリングする
といった処理を行っています。
ユーザーが存在しなかった場合は以下のように、
ユーザーが存在した場合は以下のように、
それぞれ、フラッシュメッセージが表示されるようになりました。
⑥パスワードの再設定フォームを作成する(editアクションで再設定)
ここでは、メールの有効化リンクをクリック(トークンの認証に成功)した時に表示されるパスワードの再設定フォームを作成していきます。
<div class="ui middle aligned grid mt15">
<div class="column">
<h2 class="ui teal image header">
<i class="user icon"></i>
<div class="content">
パスワード再設定フォーム
</div>
</h2>
<%= form_with(model: @user, url: password_reset_path(params[:id]), local: true, class: "ui form segment") do |form| %>
<%= hidden_field_tag :email, @user.email %>
<div class="field <%= @user.errors.include?(:password) ? 'error' : '' %>">
<%= form.label "パスワード" %>
<div class="ui left icon input">
<i class="lock icon"></i>
<%= form.password_field :password, placeholder: "パスワード" %>
</div>
<%= render 'shared/error_messages', obj: @user, key: :password %>
</div>
<div class="field <%= @user.errors.include?(:password_confirmation) ? 'error' : '' %>">
<%= form.label "パスワード(確認用)" %>
<div class="ui left icon input">
<i class="lock icon"></i>
<%= form.password_field :password_confirmation, placeholder: "パスワード(確認用)" %>
</div>
<%= render 'shared/error_messages', obj: @user, key: :password_confirmation %>
</div>
<div>
<%= form.submit "更新する", class: "ui teal submit button" %>
</div>
<% end %>
</div>
</div>
ここで、パスワード再設定フォームの中には以下のコードが仕込まれていることがわかります。
<%= hidden_field_tag :email, @user.email %>
これは、隠しフィールドとしてページ内にメールアドレスを保存するためのタグ(Railsのフォームタグヘルパー)です。
では、なぜこのタグが必要なのでしょうか。
パスワードの再設定を完了し特定ユーザーのパスワードダイジェストに保存するためには、キーとなるメールアドレスが必要不可欠です。
ユーザーのメールアドレスはパスワード再設定用の有効化リンク(メールアドレスが埋め込まれている)から一度取得していますが、
今回の再設定フォームの中でフォームを一度送信してしまうと(パスワードの再設定を完了させてしまうと)、メールアドレスの情報が消えてしまいます。
(再設定後のパスワードを更新したくても、キーとなるメールアドレスがないのでupdate
アクションで保存先のユーザーを参照できなくなってしまいます)
そこで、フォーム送信後もメールアドレスを保持しておくためにhidden_field_tag
を使い、隠しフィールドとしてメールアドレス情報をページ内に生成(保存)しておく必要があったのです。
こうすることで、フォーム送信後にupdateアクションにて、メールアドレスをキーとして変更後のパスワードを特定ユーザーのパスワードダイジェストに保存(更新)できるようになります。
以上の流れを踏まえて、edit
、update
アクション用に以下のメソッドを追加します。
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
・・・
private
# メールアドレスをキーに該当するユーザー情報をDBから取得する
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
end
メールアドレスをキーとして該当のユーザー情報を取得するget_user
メソッド、そして正統なユーザーかどうか(ユーザーが存在する、かつトークン認証済みである)を判定するvalid_user
メソッド、
これらを、before_action
でedit
アクション、update
アクションに適用してやります。
⑦再設定用のパスワードを更新する
最後に、再設定したパスワードを更新するためのupdateアクションを記述していきます。
今回のupdateアクションでは以下2つのケースを考慮する必要があります。
- パスワード再設定の有効期限が切れていないかどうか
- 新しいパスワードが空文字になっていないかどうか
①の「パスワード再設定の有効期限が切れていないかどうか」については、edit
、update
アクションにそれぞれ以下のメソッドを用意することで対応します。
before_action :check_expiration, only: [:edit, :update]
# 期限切れかどうかを確認する
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
有効期限を設定するメソッドはapp/models/user.rb
に以下のように記述します。
class User < ApplicationRecord
・・・
# パスワード再設定の有効期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
・・・
end
ここで、以下の条件式
reset_sent_at < 2.hours.ago
の「<」について、今回の場合は「〜より小さい」ではなく「〜より以前(早い)」と読むようにし、
「パスワード再設定メールの送信時刻(reset_sent_at)が、現在時刻より2時間以上前(早い)の場合」
と考えるとわかりやすいでしょう。
②の「新しいパスワードが空文字になっていないかどうか」については、
params[:user][:password].blank?
で、受け取ったパラメータの値が空白だった場合にエラーを表示するよう記述することで対応します。
if params[:user][:password].blank?
@user.errors.add(:password, :blank)
render "edit", status: :unprocessable_entity
・・・
end
最終的に、password_resetコントローラーは以下のようになります。
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
before_action :check_expiration, only: [:edit, :update]
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:notice] = "パスワード再設定用のメールを送信しました。メールをご確認の上、パスワードの再設定を行ってください。"
redirect_to root_url
else
flash.now[:error] = "メールアドレスが存在しません"
render "new", status: :unprocessable_entity
end
end
def update
if params[:user][:password].blank?
@user.errors.add(:password, :blank)
render "edit", status: :unprocessable_entity
elsif @user.update(user_params)
log_in @user
flash[:notice] = "パスワードが更新されました"
redirect_to @user
else
render "edit", status: :unprocessable_entity
end
end
def edit
end
private
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
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
# トークンが期限切れかどうか確認する
def check_expiration
if @user.password_reset_expired?
flash[:error] = "有効期限を過ぎているため、パスワードの再設定ができません。"
redirect_to new_password_reset_url
end
end
end
それでは、実際にパスワード入力時にバリデーション等が機能するか試してみましょう。
パスワードを空欄の状態、もしくはスペースのみ入力して更新ボタンを押した場合、
こちらは、パスワードが不一致の場合、
どちらもしっかりバリデーションが効いていますね。
最後に、パスワードの更新に成功した場合、
フラッシュメッセージが表示されるとともに、ユーザー詳細ページにリダイレクトされました。
これで、パスワード再設定の実装は完了です。
本番環境でのメール送信(SendGrid)
パスワード再設定の実装ができたら、実際に本番環境でメールが送信できるか確認してみましょう。
本番環境(および開発環境)におけるメールサーバーの設定方法は下記記事を参考にしてみてください。
新規ユーザー登録時に、以下のようなメールが届くかと思います。
(Gメールアドレス宛てに届いたhtmlメールの例です。メールサーバーをSendGridにした場合、送信元に以下のように「sendgrid.net 経由」と記載されているかと思います)
パスワード再設定用のリンクをクリックして、問題なければOKです。
以上です。
お疲れ様でした。
コメント