静的型付け言語原理主義者によるRailsチュートリアル(第12章)
第12章 パスワードの再設定
- パスワードの再設定はアカウント有効化の内容と似ている
- パスワード再設定の想定手順
- ログインフォームに forgot password リンクを追加
- リンクを踏むとフォームが表示され、そこにメールアドレスを入力してメールを送信すると、そのメールにパスワード再設定用のリンクが記載されている
- ログインフォームに forgot password リンクを追加
- パスワード再設定用のメイラーにリソースとデータモデルを追加し、パスワードの再設定を実現していく
- PasswordResets リソースを作成し、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的
12.1 PasswordResets リソース
- まずは PasswordResets リソースのモデリングから
- 必要なデータを User モデルに追加していく
- PasswordResets もリソースとして扱うので、標準的な RESTful URL を用意する
- パスワードを再設定するフォームが必要なので、ビューを描画するための
new
アクションとedit
アクションが必要になる
- パスワードを再設定するフォームが必要なので、ビューを描画するための
12.1.1 PssswordResets コントローラ
まずはパスワード再設定用のコントローラを作成する
new
アクションとedit
アクションも一緒にrails generate controller PasswordResets new edit --no-test-framework
単体テストをする代わりに結合テストでカバーしていく
新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、
new
create
edit
update
のルーティングも用意するRails.application.routes.draw do root 'static_pages#home' get '/help', to: 'static_pages#help' get '/about', to: 'static_pages#about' get '/contact', to: 'static_pages#contact' get '/signup', to: 'users#new' get '/login', to: 'sessions#new' post '/login', to: 'sessions#create' delete '/logout', to: 'sessions#destroy' resources :users resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] end
パスワード再設定画面へのリンクを追加する
<% provide(:title, "Log in") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= link_to "(forgot password)", new_password_reset_path %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div>
12.1.2 新しいパスワードの設定
- パスワードの再設定のデータモデルにおいても、トークン用の仮想的な属性とそれに対応するダイジェストを用意していく
パスワード再設定用のリンクはなるべく短時間で期限切れになるようにしなければならないため、再設定メールの送信時刻も記録する必要がある
$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
パスワード再設定フォームはログインフォームと似ているが、以下の違いがある
form_for
で扱うリソースがと URL が異なっている点パスワード属性が省略されている点
<% provide(:title, "Forgot password") %> <h1>Forgot password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:password_reset, url: password_resets_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.submit "Submit", class: "btn btn-primary" %> <% end %> </div> </div>
12.1.3 create アクションでパスワード再設定
- パスワード再設定フォームから送信を行った後、メールアドレスをキーとしてユーザーを DB から見つけ、パスワード再設定用トークンと送信時のタイムスタンプで DB の属性を更新する必要がある
続いてルート URL にリダイレクトし、フラッシュメッセージを表示
送信が無効の場合は
new
ページを出力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[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end end
User モデルにはパスワード再設定用のメソッドを追加する
class User < ApplicationRecord attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email before_create :create_activation_digest . . . # アカウントを有効にする def activate update_attribute(:activated, true) update_attribute(:activated_at, Time.zone.now) end # 有効化用のメールを送信する def send_activation_email UserMailer.account_activation(self).deliver_now end # パスワード再設定の属性を設定する 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 private # メールアドレスをすべて小文字にする def downcase_email self.email = email.downcase end # 有効化トークンとダイジェストを作成および代入する def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end end
この時点では無効なメールアドレスを入力した場合に正常に動作する
- 正しいメールアドレスを送信した場合にも正常に動作するためには、パスワード再設定用のメイラーメソッドを定義する必要がある
12.2 パスワード再設定のメール送信
- 残りはパスワード再設定に関するメールを送信するところ
12.2.1 パスワード再設定のメールとテンプレート
- User メイラーのときと同様のリファクタリングをパスワード再設定にも行っていく
まずは User メイラーに
password_reset
メソッドを追加してパスワード再設定のリンクをメール送信できるようにする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
続いてパスワード再設定メールのテキストテンプレートを定義
To reset your password click the link below: <%= edit_password_reset_url(@user.reset_token, email: @user.email) %> This link will expire in two hours. If you did not request your password to be reset, please ignore this email and your password will stay as it is.
最後にパスワード再設定メールの HTML テンプレートを定義
<h1>Password reset</h1> <p>To reset your password click the link below:</p> <%= link_to "Reset password", edit_password_reset_url(@user.reset_token, email: @user.email) %> <p>This link will expire in two hours.</p> <p> If you did not request your password to be reset, please ignore this email and your password will stay as it is. </p>
アカウント有効化メールのときと同様に、Rails のメールプレビュー機能でパスワード再設定メールをプレビューできるようにする
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer 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
12.2.2 送信メールのテスト
アカウント有効化のテストと同様に、メイラーメソッドのテストを書く
require 'test_helper' class UserMailerTest < ActionMailer::TestCase test "account_activation" do user = users(:michael) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end test "password_reset" do user = users(:michael) user.reset_token = User.new_token mail = UserMailer.password_reset(user) assert_equal "Password reset", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.reset_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end end
12.3 パスワードを再設定する
- 送信メールを生成できたので、PasswordResets コントローラの
edit
アクションを実装する
12.3.1 edit アクションで再設定
- パスワード再設定送信メール記載のリンクを機能させるには、パスワード再設定フォームを表示するビューが必要
- パスワード入力フィールドと確認用フィールドだけでよい
- メールアドレスをキーとしてユーザーを検索するためには、
edit
アクションとupdate
アクションの両方でメールアドレスが必要なため、作業が少し面倒 edit
アクションでメールアドレスを取り出すことに問題はないが、フォームを一度送信してしまうとこの情報は消えてしまう- メールアドレスを保持するため、隠しフィールドとしてページ内に保存する
フォームから送信したときに他の情報と一緒にメールアドレスが送信されるようになる
<% provide(:title, 'Reset password') %> <h1>Reset password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %> <%= render 'shared/error_messages' %> <%= hidden_field_tag :email, @user.email %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Update password", class: "btn btn-primary" %> <% end %> </div> </div>
このフォームを描画するために PasswordResets コントローラの
edit
アクションを更新するclass PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] . . . def edit end 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 end
12.3.2 パスワードを更新する
- フォームから新しいパスワードが送信されるので、それに対応する
update
アクションが必要- 以下のケースを考慮する
- パスワード再設定の有効期限が切れていないか
- 無効なパスワードであれば失敗させる
- 新しいパスワードが空文字になっていないか
- 新しいパスワードが正しければ更新する
- 以下のケースを考慮する
パスワード再設定の有効期限切れチェックを除くと以下のようになる
class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] before_action :check_expiration, only: [:edit, :update] # (1) への対応 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[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end def update if params[:user][:password].empty? # (3) への対応 @user.errors.add(:password, :blank) render 'edit' elsif @user.update_attributes(user_params) # (4) への対応 log_in @user flash[:success] = "Password has been reset." redirect_to @user else render 'edit' # (2) への対応 end end private def user_params params.require(:user).permit(:password, :password_confirmation) end # beforeフィルタ 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[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end end
User モデルにパスワード再設定用メソッドを追加する
class User < ApplicationRecord . . . # パスワード再設定の期限が切れている場合はtrueを返す def password_reset_expired? reset_sent_at < 2.hours.ago end private . . . end
12.3.3 パスワードの再設定をテストする
- パスワード再設定に成功した場合と失敗した場合の統合テストを作成する
- 最初は forgot password フォームを表示して無効なメールアドレスを送信し、次はそのフォームで有効なメールアドレスを送信する
- 後者ではパスワード再設定用トークンが作成され、再設定用メールが送信される
メールのリンクを開いて無効な情報を送信し、そのリンクから有効な情報を送信する
require 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:michael) end test "password resets" do get new_password_reset_path assert_template 'password_resets/new' # メールアドレスが無効 post password_resets_path, params: { password_reset: { email: "" } } assert_not flash.empty? assert_template 'password_resets/new' # メールアドレスが有効 post password_resets_path, params: { password_reset: { email: @user.email } } assert_not_equal @user.reset_digest, @user.reload.reset_digest assert_equal 1, ActionMailer::Base.deliveries.size assert_not flash.empty? assert_redirected_to root_url # パスワード再設定フォームのテスト user = assigns(:user) # メールアドレスが無効 get edit_password_reset_path(user.reset_token, email: "") assert_redirected_to root_url # 無効なユーザー user.toggle!(:activated) get edit_password_reset_path(user.reset_token, email: user.email) assert_redirected_to root_url user.toggle!(:activated) # メールアドレスが有効で、トークンが無効 get edit_password_reset_path('wrong token', email: user.email) assert_redirected_to root_url # メールアドレスもトークンも有効 get edit_password_reset_path(user.reset_token, email: user.email) assert_template 'password_resets/edit' assert_select "input[name=email][type=hidden][value=?]", user.email # 無効なパスワードとパスワード確認 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "foobaz", password_confirmation: "barquux" } } assert_select 'div#error_explanation' # パスワードが空 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "", password_confirmation: "" } } assert_select 'div#error_explanation' # 有効なパスワードとパスワード確認 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "foobaz", password_confirmation: "foobaz" } } assert is_logged_in? assert_not flash.empty? assert_redirected_to user end end
12.4 本番環境でのメール送信(再掲)
- SendGrid でのメール送信
- クレジットカード登録が必要なので再度スキップ
12.5 最後に
- パスワード再設定の実装により、サンプルアプリケーションのユーザー登録・ログイン・ログアウトの仕組みは本物に近いレベルとなった
所感
Webサービスにパスワードリセットの機能はつきものだが、Rails での実装の一例を知ることができた。Rails に従うのが一番と考えると、一例でなくこれが全てなのかもしれない。ダイジェストやトークンを使用しての認証は当たり前のように思えるが、意外と世のサービスがこのように実装されているかどうかは疑わしいのではという気もしてきている。(前職での開発に思いを馳せながら)