見出し画像

railsチュートリアル挑戦記 第12章 パスワードの再設定

railsチュートリアルをやりながらメモしたことをそのまま記述しています。

第12章パスワードの再設定

アカウント有効化のときと異なり、パスワードを再設定する場合はビューを1つ変更する必要があるまた、新しいフォームが新たに2つ(メールレイアウト用と新しいパスワードの送信用)必要になる

ログイン時に表示されるforgotリンクを追加する

更に、forgot passwordリンクをクリックするとフォームが表示され、そこにメールアドレスを入力してメールを送信すると、そのメールにパスワード再設定用のリンクが記載されている。
この再設定用のリンクをクリックすると、ユーザーのパスワードを再設定してよいか確認を求めるフォームが表示される

11章で生成したメイラーにリソースとデータモデルを追加して、パスワードの再設定を実現する

実際の実装は12 3から

アカウント有効化と似ており、PasswordResetsリソースを作成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的

順番は下記
①ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
②該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する
③再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
④ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する(トークンを認証する)
⑤認証に成功したら、パスワード変更用のフォームをユーザーに表示する

・12 1 PasswordResetsリソース

必要なデータをUserモデルに追加していく
11章ではeditアクションのみだったが、今回はフォームが必要なのでnewアクションとedhitアクションが必要

いつものようにトピックブランチを作成する

git checkout -b password-reset

トピックブランチ作りすぎてどれが最新のものか分かりづらくなったので下記コマンドをCliborに登録
git branch --sort=authordate
git branch --sort=committerdate

上が自身の持つトピックブランチタイムスタンプ昇順で、
下がコミットしたトピックブランチのタイムスタンプ降順?
とりあえずどちらも最新のトピックブランチが一番下に表示されることを確認
違いはおいおい覚えていく

・12 1 1 PasswordResetsコントローラ

パスワード設定用のコントローラを作成する
今回はビューも扱うため、newアクションとedhitアクションも一緒に生成する

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

ログ
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ rails generate controller PasswordResets new edit --no-test-framework 
                                                           k
Running via Spring preloader in process 4311
     create  app/controllers/password_resets_controller.rb
      route  get 'password_resets/edit'
      route  get 'password_resets/new'
     invoke  erb
     create    app/views/password_resets
     create    app/views/password_resets/new.html.erb
     create    app/views/password_resets/edit.html.erb
     invoke  helper
     create    app/helpers/password_resets_helper.rb
     invoke  assets
     invoke    coffee
     create      app/assets/javascripts/password_resets.coffee
     invoke    scss
     create      app/assets/stylesheets/password_resets.scss
ec2-user:~/environment/sample_app (password-reset) $ 
----------------------------

--no-test-frameworkってなに?
これは単体テストを生成しないということ。
統合テストでカバーするため今回は生成しない。

新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるため、new,create,edit,updateのルーティングも用意する


パスワード再設定用リソースを追加する
config/routes.rb
----------------------------
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
----------------------------

元々のpasswordのルーティングが自動生成で追加されたけどそれを削除
password関連のルーティングをちょこっと手直しして、
resources :password~に変更した


これで、以下の通りとなる
----------------------------
HTTPリクエスト	URL	Action	名前付きルート
GET	/password_resets/new	new	new_password_reset_path
POST	/password_resets	create	password_resets_path
GET	/password_resets/<token>/edit	edit	edit_password_reset_url(token)
PATCH	/password_resets/<token>	update	password_reset_url(token)
----------------------------

new_password_reset_pathは、new(GET)に飛ぶ


パスワード再設定画面へのリンクを追加する
app/views/sessions/new.html.erb
----------------------------
<% 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>
----------------------------

この時点でgreenになる

以下のエラー出たけど初見
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ rails test
FATAL: Listen error: unable to monitor directories for changes.
Visit https://github.com/guard/listen/wiki/Increasing-the-amount-of-inotify-watchers for info on how to fix this.
----------------------------
Google翻訳
----------------------------
ec2-user:〜/ environment / sample_app(password-reset)$ rails test
致命的:リッスンエラー:ディレクトリの変更を監視できません。
これを修正する方法については、https://github.com/guard/listen/wiki/Increasing-the-amount-of-inotify-watchersにアクセスしてください。
----------------------------


https://github.com/guard/listen/wiki/Increasing-the-amount-of-inotify-watchers
を参照して日本語訳して読んでみると、技術的な詳細に興味がなければ
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
を実行してくださいとあったので、実行

ログ
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
fs.inotify.max_user_watches=524288
net.ipv4.ip_forward = 0
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.default.accept_source_route = 0
kernel.sysrq = 0
kernel.core_uses_pid = 1
net.ipv4.tcp_syncookies = 1
kernel.msgmnb = 65536
kernel.msgmax = 65536
kernel.shmmax = 68719476736
kernel.shmall = 4294967296
fs.inotify.max_user_watches = 524288
ec2-user:~/environment/sample_app (password-reset) $ 
----------------------------

再度rails test

ログ
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ rails test
Running via Spring preloader in process 4679
Started with run options --seed 56563
 44/44: [==============] 100% Time: 00:00:05, Time: 00:00:05
Finished in 5.33255s
44 tests, 184 assertions, 0 failures, 0 errors, 0 skips
ec2-user:~/environment/sample_app (password-reset) $ 
----------------------------

いつものrails testのログがでたのでひと安心
これつまづくやろ・・・。


・12 1 2 新しいパスワードの設定

トークン用の仮想的な属性とそれに対応するダイジェストを用意する
トークンを平文でデータベースに保存してしまうとセキュリティ上の問題があるため、必ずダイジェストを利用する

再設定用のリンクはなるべく短時間で期限切れになるようにしなければならない
reset_digest属性(ハッシュ)とreset_sent_at属性(時間)をUserモデルに追加する

usersモデルは下記の通り
id(数字:自動生成)
name(文字列)
email(文字列)
create_at(時間:自動生成)
updated_at(時間:自動生成)

password_digest(ハッシュ)

remember_digest(ハッシュ)

activation_digest(ハッシュ)
activated(有効化:無効化)
activated_at(時間)

reset_digest(ハッシュ)
reset_sent_at(時間)

上記に従って、マイグレーションを作成する

rails generate migration add_reset_to_users reset_digest:string \
reset_sent_at:datetime

そして適用

rails db:migrate

ログインフォームのコードは下記

ログインフォームのコード (再掲)
app/views/sessions/new.html.erb
----------------------------
<% 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 %>
     <%= 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>
----------------------------

それを改変して下記を作成
form_forで扱うリソースとURLが異なっている点と、パスワード属性を省略した点が違う

新しいパスワード再設定画面ビュー
app/views/password_resets/new.html.erb
----------------------------
<% 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>
----------------------------

これで、Forgot password画面が作成された

・12 1 3 createアクションでパスワード再設定

フォームから送信を行ったあと、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要がある

それに続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する

送信が無効の場合、ログインと同様にnewページを出力してflash.nowメッセージを表示

パスワード再設定用のcreateアクション
app/controllers/password_resets_controller.rb
----------------------------
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モデルにパスワード再設定用メソッドを追加する
app/models/user.rb
----------------------------
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 パスワード再設定のメール送信

パスワード再設定に関するメールを送信する部分を作っていく

Userメイラーを生成したときに、デフォルトのpassword_resetメソッドもまとめて生成されているはず


・12 2 1 パスワード再設定のメールとテンプレート

メイラーにパスワードリセット時のメールアドレスと件名を設定する

パスワード再設定のリンクをメール送信する
app/mailers/user_mailer.rb
----------------------------
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
----------------------------

パスワードリセット時の文章(テキスト版)

パスワード再設定のテンプレート (テキスト)
app/views/user_mailer/password_reset.text.erb
----------------------------
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版)

パスワード再設定のテンプレート (HTML)
app/views/user_mailer/password_reset.html.erb
----------------------------
<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>
----------------------------

テストコードを書く

パスワード再設定のプレビューメソッド (完成)
test/mailers/previews/user_mailer_preview.rb
----------------------------
# 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
----------------------------

これで
http://ごにょごにょ/rails/mailers/user_mailer/password_reset
http://ごにょごにょ/rails/mailers/user_mailer/password_reset.txt
でプレビューできるようになった

有効なメールアドレスを送信した場合
サーバーログに表示されたパスワード再設定メールの例
----------------------------
Sent mail to michael@michaelhartl.com (66.8ms)
Date: Mon, 06 Jun 2016 22:00:41 +0000
From: noreply@example.com
To: michael@michaelhartl.com
Message-ID: <8722b257d04576a@mhartl-rails-tutorial-953753.mail>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
boundary="--==_mimepart_5407babbe3505_8722b257d045617";
charset=UTF-8
Content-Transfer-Encoding: 7bit

----==_mimepart_5407babbe3505_8722b257d045617
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: 7bit
To reset your password click the link below:
https://rails-tutorial-mhartl.c9users.io/password_resets/3BdBrXe
QZSWqFIDRN8cxHA/edit?email=michael%40michaelhartl.com
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.
----==_mimepart_5407babbe3505_8722b257d045617
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<a href="https://rails-tutorial-mhartl.c9users.io/
password_resets/3BdBrXeQZSWqFIDRN8cxHA/
edit?email=michael%40michaelhartl.com">Reset password</a>
<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>
----==_mimepart_5407babbe3505_8722b257d045617--
----------------------------


・12 2 2 送信メールのテスト

メイラーのテストを書いていく

パスワード再設定用メイラーメソッドのテストを追加する green
test/mailers/user_mailer_test.rb
----------------------------
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
----------------------------

rails testでgreenになることを確認

ちなみにCGI.escape(user.email)ではなくuser.emailや[user.email]だとテストがredになるはず

user.emailの場合
ログ
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ rails test
Running via Spring preloader in process 5325
Started with run options --seed 18925
FAIL["test_account_activation", UserMailerTest, 0.428123911000057]
test_account_activation#UserMailerTest (0.43s)
       Expected /michael@example\.com/ to match # encoding: US-ASCII
       "\r\n----==_mimepart_5e10a4e8bbd17_14cd4959a861732\r\nContent-Type: text/plain;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHi Michael Example,\r\n\r\nWelcome to the Sample App! Click on the link below to activate your account:\r\n\r\nhttp://example.com/account_activations/v-h6Bg9-z8w0J2T1-8pQ9g/edit?email=michael%40example.com\r\n\r\n----==_mimepart_5e10a4e8bbd17_14cd4959a861732\r\nContent-Type: text/html;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\n<!DOCTYPE html>\r\n<html>\r\n  <head>\r\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\r\n    <style>\r\n      /* Email styles need to be inline */\r\n    </style>\r\n  </head>\r\n\r\n  <body>\r\n    <h1>Sample App</h1>\r\n\r\n<p>Hi Michael Example,</p>\r\n\r\n<p>\r\nWelcome to the Sample App! Click on the link below to activate your account:\r\n</p>\r\n\r\n<a href=\"http://example.com/account_activations/v-h6Bg9-z8w0J2T1-8pQ9g/edit?email=michael%40example.com\">Activate</a>\r\n  </body>\r\n</html>\r\n\r\n----==_mimepart_5e10a4e8bbd17_14cd4959a861732--\r\n".
       test/mailers/user_mailer_test.rb:14:in `block in <class:UserMailerTest>'
 45/45: [==============] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.57483s
45 tests, 191 assertions, 1 failures, 0 errors, 0 skips
ec2-user:~/environment/sample_app (password-reset) $ 
----------------------------
[user.email]の場合
ログ
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ rails test
Running via Spring preloader in process 5402
Started with run options --seed 7856
FAIL["test_account_activation", UserMailerTest, 1.4240181469999698]
test_account_activation#UserMailerTest (1.42s)
       Expected /michael@example\.com/ to match # encoding: US-ASCII
       "\r\n----==_mimepart_5e10a575345ed_151a4959a821455\r\nContent-Type: text/plain;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHi Michael Example,\r\n\r\nWelcome to the Sample App! Click on the link below to activate your account:\r\n\r\nhttp://example.com/account_activations/MHJUZoDUbASOfIGndM7_sg/edit?email=michael%40example.com\r\n\r\n----==_mimepart_5e10a575345ed_151a4959a821455\r\nContent-Type: text/html;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\n<!DOCTYPE html>\r\n<html>\r\n  <head>\r\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\r\n    <style>\r\n      /* Email styles need to be inline */\r\n    </style>\r\n  </head>\r\n\r\n  <body>\r\n    <h1>Sample App</h1>\r\n\r\n<p>Hi Michael Example,</p>\r\n\r\n<p>\r\nWelcome to the Sample App! Click on the link below to activate your account:\r\n</p>\r\n\r\n<a href=\"http://example.com/account_activations/MHJUZoDUbASOfIGndM7_sg/edit?email=michael%40example.com\">Activate</a>\r\n  </body>\r\n</html>\r\n\r\n----==_mimepart_5e10a575345ed_151a4959a821455--\r\n".
       test/mailers/user_mailer_test.rb:14:in `block in <class:UserMailerTest>'
FAIL["test_password_reset", UserMailerTest, 1.4349113520001993]
test_password_reset#UserMailerTest (1.44s)
       Expected ["michael@example.com"] to match # encoding: US-ASCII
       "\r\n----==_mimepart_5e10a575373ad_151a4959a821520\r\nContent-Type: text/plain;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nTo reset your password click the link below:\r\n\r\nhttp://example.com/password_resets/AmOk-1T-YvHW7ssLhiPE6A/edit?email=michael%40example.com\r\n\r\nThis link will expire in two hours.\r\n\r\nIf you did not request your password to be reset, please ignore this email and\r\nyour password will stay as it is.\r\n\r\n----==_mimepart_5e10a575373ad_151a4959a821520\r\nContent-Type: text/html;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\n<!DOCTYPE html>\r\n<html>\r\n  <head>\r\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\r\n    <style>\r\n      /* Email styles need to be inline */\r\n    </style>\r\n  </head>\r\n\r\n  <body>\r\n    <h1>Password reset</h1>\r\n\r\n<p>To reset your password click the link below:</p>\r\n\r\n<a href=\"http://example.com/password_resets/AmOk-1T-YvHW7ssLhiPE6A/edit?email=michael%40example.com\">Reset password</a>\r\n\r\n<p>This link will expire in two hours.</p>\r\n\r\n<p>\r\nIf you did not request your password to be reset, please ignore this email and\r\nyour password will stay as it is.\r\n</p>\r\n  </body>\r\n</html>\r\n\r\n----==_mimepart_5e10a575373ad_151a4959a821520--\r\n".
       test/mailers/user_mailer_test.rb:25:in `block in <class:UserMailerTest>'
 45/45: [==============] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.67825s
45 tests, 191 assertions, 2 failures, 0 errors, 0 skips
ec2-user:~/environment/sample_app (password-reset) $ 
----------------------------

・12 3 パスワードを再設定する

editアクションの実装を進める。
統合テストも書いてく


・12 3 1 editアクションで再設定

パスワード再設定フォームを表示するビューが必要
このビューはユーザーの編集フォームと似ているが、今回はパスワード入力フィールドと確認用フィールドのみでOK

面倒な点があり、メールアドレスをキーとしてユーザーを検索するためには、editアクションとupdateアクションの両方でメールアドレスが必要

しかし、フォームを一度送信してしまうと、updateでこの情報が消える
それを解決するのが、ページ内に@userのemailをこっそり誰にも見えないように引っ張ってくる手法


パスワード再設定のフォーム
app/views/password_resets/edit.html.erb
----------------------------
<% 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>
----------------------------

fを使っていない理由は、params[:email]に保存されない。つけるとparams[:user][:email]に保存される、という違いがある。

なぜそれがダメなのかはイマイチわかってない・・・。


editとupdateでユーザー情報を取得して、正しいユーザーでなければルートページに飛ばす

パスワード再設定のeditアクション
app/controllers/password_resets_controller.rb
----------------------------
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 パスワードを更新する

アカウント有効化にするときはeditで有効化していたが、
今回はeditでフォームから新しいパスワードを送信するようになっている
なので、フォームからの送信に対応するupdateアクションが必要になる

updateアクションでは以下の4つを考慮する
①パスワード再設定の有効期限が切れていないか
before_actionで対応
(password_reset_expired?はあとで解説)
②無効なパスワードであれば失敗させる
before_actionで対応
③新しいパスワードが空文字列になっていないか
特殊処理
④新しいパスワードが正しければ更新する
before_actionで対応


パスワード再設定のupdateアクション
app/controllers/password_resets_controller.rb
----------------------------
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
----------------------------

reset_sent_at < 2.hours.agoは、
reset_sent_atより2時間早い時刻という意味

・・・?

混乱しやすいから暗記してしまえばいいか
「reset_sent_at < 2.hours.ago」は、2時間以上経っているという認識に変えよう!

Userモデルにパスワード再設定用メソッドを追加する
app/models/user.rb
----------------------------
class User < ApplicationRecord
 .
 .
 .
 # パスワード再設定の期限が切れている場合はtrueを返す
 def password_reset_expired?
   reset_sent_at < 2.hours.ago
 end
 private
   .
   .
   .
end
----------------------------

これで、パスワードの再設定が失敗したら失敗した理由が表示される
(:dangerは赤色の文字)

パスワードの再設定が成功すると、Successメッセージが出てプロフィールページに飛ぶ(:successは緑色の文字)


・12 3 3 パスワードの再設定をテストする

送信に成功した場合と失敗した場合の統合テストを作成する

rails generate integration_test password_resets

ログ
----------------------------
ec2-user:~/environment/sample_app (password-reset) $ rails generate integration_test password_resets
Running via Spring preloader in process 5906
     invoke  test_unit
     create    test/integration/password_resets_test.rb
----------------------------

12.18: パスワード再設定の統合テスト
test/integration/password_resets_test.rb
----------------------------
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
----------------------------

inputというのが出てくる
これは、inputタグに正しい値が入っているかどうかを確認するためのもの

ここでrails testがgreenになるはず!

演習1 冗長をなくそう

update_columnsを使用するテンプレート
app/models/user.rb
----------------------------
class User < ApplicationRecord
 attr_accessor :remember_token, :activation_token, :reset_token
 before_save   :downcase_email
 before_create :create_activation_digest
 .
 .
 .
 # アカウントを有効にする
 def activate
   update_columns(activated: true, 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_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
 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
----------------------------

演習2 パスワードの期限切れテストをしよう
パスワード再設定の期限切れのテストgreen
test/integration/password_resets_test.rb
----------------------------
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
 def setup
   ActionMailer::Base.deliveries.clear
   @user = users(:michael)
 end
 .
 .
 .
 test "expired token" do
   get new_password_reset_path
   post password_resets_path,
        params: { password_reset: { email: @user.email } }
   @user = assigns(:user)
   @user.update_attribute(:reset_sent_at, 3.hours.ago)
   patch password_reset_path(@user.reset_token),
         params: { email: @user.email,
                   user: { password:              "foobar",
                           password_confirmation: "foobar" } }
   assert_response :redirect
   follow_redirect!
   assert_match "expired", response.body
 end
end
----------------------------


演習3 パスワードが更新されたら更新できないようにしよう

パスワード再設定が成功したらダイジェストをnilにする
app/controllers/password_resets_controller.rb
----------------------------
class PasswordResetsController < ApplicationController
 .
 .
 .
 def update
   if params[:user][:password].empty?
     @user.errors.add(:password, :blank)
     render 'edit'
   elsif @user.update_attributes(user_params)
     log_in @user
     @user.update_attribute(:reset_digest, nil)
     flash[:success] = "Password has been reset."
     redirect_to @user
   else
     render 'edit'
   end
 end
 .
 .
 .
end
----------------------------


演習4 演習3用のテストを作成しよう
test/integration/password_resets_test.rbの
password resetsテストの末尾に
assert_nil user.reload['reset_digest']を追加

・12 4 本番環境でのメール送信 (再掲)


source <(curl -sL https://cdn.learnenough.com/heroku_install)
heroku login --interactive
メールアドレス入力
パスワード入力
heroku create
git remote set-url heroku ごにょごにょ.gitのurlかく
heroku rename jun-killer-12

https://jun-killer-12.herokuapp.com/
ができた!

以下のhostに、アプリ名を入れる!

Railsのproduction環境でSendGridを使う設定
config/environments/production.rb
----------------------------
Rails.application.configure do
 .
 .
 .
 config.action_mailer.raise_delivery_errors = true
 config.action_mailer.delivery_method = :smtp
 host = '<ここ注意ね!自分の作ったURL!!>.herokuapp.com'
 config.action_mailer.default_url_options = { host: host }
 ActionMailer::Base.smtp_settings = {
   :address        => 'smtp.sendgrid.net',
   :port           => '587',
   :authentication => :plain,
   :user_name      => ENV['SENDGRID_USERNAME'],
   :password       => ENV['SENDGRID_PASSWORD'],
   :domain         => 'heroku.com',
   :enable_starttls_auto => true
 }
 .
 .
 .
end
----------------------------

新規ファイルを追加しているのでcommit -amは使えない

git add -A
git commit -m "Add password reset"
git checkout master
git merge password-reset
rails test
git push origin master

heroku maintenance:on


本番環境ではSendGridというHerokuアドオンを利用してアカウントを検証する必要がある
本チュートリアルではstarter tierを使う

heroku addons:create sendgrid:starter

失敗するようなら

heroku addons:add sendgrid:starter

を試してみる

今回は上のコマンドでいけた!

そして下記のコマンドで色々表示できた!
heroku config:get SENDGRID_USERNAME
heroku config:get SENDGRID_PASSWORD

git push heroku master
heroku pg:reset DATABASE
jun-killer-12
heroku run rails db:migrate
heroku run rails db:seed
heroku restart
heroku maintenance:off

https://jun-killer-12.herokuapp.com

いろいろ確認してみる
①ログインからforgot passwordに飛んでみる

データベースにないe-mailを設定してみる

Email address not found

期待通りの動作

②アカウントを作成

アカウント作成用アドレスが飛んでくる

メイラーからアカウントを有効化

ログアウト

ログイン画面

forgot password

さっき登録したメールアドレスを入力

Email sent with password reset instructions

メイラーからパスワードリセットのリンクをたどる

空のパスワードでやろうとしてみる

The form contains 1 error.
Password can't be blank

パスワード1文字でやろうとしてみる

The form contains 1 error.
Password is too short (minimum is 6 characters)

パスワード1文字の差異ありでやってみる

The form contains 2 errors.
Password confirmation doesn't match Password
Password is too short (minimum is 6 characters)

パスワード8文字の差異ありでやってみようとする

The form contains 1 error.
Password confirmation doesn't match Password

このあとでメイラーからまたリンクを辿ってみる

それは普通に飛べる

ちゃんと再設定してみる

Password has been reset.
と表示され、プロフィール画面へ飛ぶ

再度メイラーからまたリンクで辿ってみる

トップページが表示される

ログアウト

ログイン

前のパスワードでログインしてみようとする

Invalid email/password combination

新パスワードでログインしてみようとする

普通にアカウントページに飛ぶ

期待通りの動作

イイネ!!!

成果物

https://jun-killer-12.herokuapp.com

・12 5 最後に


パスワード再設定の実装が終わったことにより、サンプルアプリケーションのユーザー登録・ログイン・ログアウトの仕組みは、本物のアプリケーションと近いレベルに仕上がった。
残りの章では、Twitterのようなマイクロポスト機能(13章)と、フォロー中のユーザーの投稿を表示するステータスフィード機能(14章)を実装する
画像アップロード、カスタマイズしたデータベースへの問い合わせ、has_manyやhas_many :throughを使った高度なデータベースモデリングを扱う

・12 5 1 本章のまとめ

パスワードの再設定は、ActiveRecordオブジェクトではないが、セッションやアカウント有効化の場合と同様に、リソースでモデル化できる

Railsは、メール送信で扱うAction Mailerのアクションとビューを生成することができる
ActionMailerではデキストメールとHTMLメールの両方を利用できる
メイラーアックションで定義したインスタンス変数は、他のアクションやビューと同様、メイラーのビューから参照できる
メイラーアクションで定義したインスタンス変数は、他のアクションやビューと同様、メイラーのビューから参照できる
パスワードを再設定させるために、生成したトークンを使って一意のURLを作る
より安全なパスワード再設定のために、ハッシュ化したトークン(ダイジェスト)を使う
メイラーのテストと統合テストは、どちらもUserメイラーの振る舞いを確認するのに有用
SendGridを使うとproduction環境からメールを送信できる


・12 6 証明: 期限切れの比較

expiredは期限切れの意味


この記事が気に入ったらサポートをしてみませんか?