パスワードの再設定
必要なのは、ユーザーにemailを入力してもらうためのフォーム、アクション、こちらで受け取るためのアクション、
そして、urlをクリックしてもらったら、パスワードを再設定するためのeditアクションとフォーム、updateアクションが必要となる。
まずはPasswordResetsリソースを作る必要がある。
$ rails generate controller PasswordResets new edit --no-test-framework
--no-test-frameworkは今現在の統合テストで十分足りている時にかく。
これにより、rails g controllerに付随するテストをスキップすることができる。
ルーティングを設定する。
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>
カラムを追加しよう
フォームを作る前に、Userモデルにパスワード再設定に必要なカラムを追加しよう
$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
これはアカウント有効化と同じで、再設定用に作ったトークンのハッシュ値を入れておくカラムと、それが作られた時間を入れておくカラムを作る。
booleanの時はデフォルト値を入れていたが、今回はtrue,falseでもないのでデフォルト値の設定は不要。
newアクションのviewを作っていく
<% 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>
form_forのところに注目しよう
<%= form_for(:password_reset, url: password_resets_path) do |f| %>
第一引数は:password_resetになっている。
リソース名をシンボルにして渡してあげることにより、デフォルトでpostリクエストが送りつけられる。(変えたければ、method :PATCHのようにすれば良い)password_resetsコントローラのcreateアクションにいくように設定される。
で、取り出すときも、params[:password_reset][:email]のようにすれば取り出すことができる。
パスワード再設定用の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[: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を引っ張ってくる時に.downcaseメソッドを使っていることに注目。
これはユーザーがメールアドレスを大文字で入力してくることに配慮している。
次は if @userを見ていこう。
これはユーザーがタイポしてしまうケースに配慮している。
もしユーザーが見つからなければ、再度newアクションにrenderされる。
Userモデルに必要な情報を定義
今の状態だと、create_reste_digestメソッドや、send_password_reset_emailメソッド、それに、パスワードリセットに必要なトークンの平文(仮想的な属性)がないので、それを定義していく必要がある。
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
ここで注目すべきは
# パスワード再設定の属性を設定する
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
メールアドレスを入力し、ボタンを押し、再設定を申し込まれた時点でresent_sent_atカラムに値が格納されている。
前回のアカウント有効化の時は、ユーザーがリンクを踏んだ時に初めて情報が格納された。
なぜこうしているのか?
それは、パスワード再設定用のリンクに期限をつけたいからである。
(文字列を打ち込む時間が増えて攻撃される可能性があるから?)
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
これ、なんでやるかというと、@userをメールのviewで使えるようにするため、
また、このメソッドを使うと、メールオブジェクトが返るから、それをdeliver_nowメソッドなどで送ることができるから。
メールの文面を変える
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.
<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>
メールのプレビューを確認しよう
# 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
これにより、メールオブジェクトが生成されるので、指定のurlでメールのプレビューを確認することができる。
パスワード再設定用メイラーメソッドのテストを追加する
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
この状態でもテスト通る。
次は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>
form_forの引数に@userが送られているが、
これは前回もあったように@userが既存のオブジェクトなら、patchリクエストで送信されるということ。
また、urlを指定しないと、Userコントローラのupdateアクションに行ってしまうので、このように指定している。
今回は、攻撃に備えるために、editアクションでもupdateアクションでもauthenticate?メソッドを実行するらしい。
ただ、これにはupdateアクションでも@user.emailの情報がなければいけない。
これをどうするか?
一番簡単なのは、もう一度ユーザーにメールアドレスを打ち込んでもらうこと。
これをスマートにやるには、
hidden_field_tag
<%= hidden_field_tag :email, @user.email %>
hidden_field_tagはユーザーに入力させる必要がないものを扱う時に使う。
これはユーザーからは見えず、入力させることなく、@user.emailを次に次にと回していくことができる。
コントローラを実装しよう
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
valid_userメソッドを見ていこう。
まず、emailからユーザーを引っ張ってきているが、万が一nilになってしまってることに備えて@userを条件に入れている。
また、DBに保存されてはいるけど、まだactivateされていないユーザーかもしれない。
そして、reset_digestとreset_tokenを比較して一致したら正しいユーザーですよーってこと。
また、1つ目のbefore_actionに関しては、editとupdateアクションがどちらもユーザーインスタンスを参照するので、その処理をまとめたもの。
パスワードの更新の4つの処理
1.パスワード再設定の有効期限が切れていないか
2.無効なパスワードであれば失敗させる (失敗した理由も表示する)
3.新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4.新しいパスワードが正しければ、更新する
以上4つの条件を踏まえて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
まずはこれを見ていこう
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
@user.errors.add(:password, :blank)
現在パスワードにはallow nilが設定されているので、空欄のまま更新できてしまう。
なので、もしパスワードが空なら、バリデーションが発動する前に問答無用でエラーを出そうねーってこと。
また、ますアサインメント脆弱性を補完するために、
ストロングパラメータを使う。
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
ただ、
if @user.password_reset_expired?
のインスタンスメソッドがまだ定義されていないので、定義する。
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
パスワード再設定のテストする
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
これは演習でいい。