🦓

[Rails]パスワードリセット 4/20

2023/06/17に公開

はじめに

前回の記事ではパスワードの更新機能を実装しました。
今回ではパスワードリセット機能を実装していきます。
ユーザがログイン画面のパスワードを忘れたをクリックし、メールアドレスを入れてパスワードリセットをリクエストし、リセットリンクをメールに届くようにします。

パスワード更新

環境

Rails 6.1.7.3
ruby 3.0.0

流れ

1. パスワードリセットリクエスト用ルートを追加する
2. リセット用コントローラーを作成する
3. パスワードリセット用ビューを作成する
4. Mailerを作成する
5. パスワードリセット用ルートを追加する
6. リセット用アクションを作成する
7. Mailerの設定を追加する

routes.rbを編集する

パスワードリセット用のルーティングを追加します。

config/routes.rb
Rails.application.routes.draw do
...
  get "password/reset", to: "password_resets#new"
  post "password/reset", to: "password_resets#create"
end

リセット用コントローラーを作成する

$ bin/rails g controller Password_resets
Running via Spring preloader in process 93684
      create  app/controllers/password_resets_controller.rb
      invoke  erb
      create    app/views/password_resets
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
    def new

    end

    def create
        
    end
end

パスワードリセット用ビューを作成する

app/views/password_resets/new.html.erb
<%= form_with url: password_reset_path do |form| %>
  <div class="form-group">
    <%= form.label :email %>
    <%= form.text_field :email, placeholder: "user@example.com", class: "form-control mb-3"  %>
  </div>
  <%= form.submit 'パスワードリセットを申請する', class:'btn btn-primary' %>
<% end %>

ログインフォームにパスワードリセットリンクを追加する

app/views/sessions/new.html.erb
  <%= link_to "パスワードを忘れた?", password_reset_path, class: 'link-secondary d-inline-block ms-3' %>

Mailerを作成する

bin/rails g mailer Password_reset
      create  app/mailers/password_reset_mailer.rb
      invoke  erb
      create    app/views/password_reset_mailer

app/views/password_reset_mailer内にreset.text.erbreset.html.erbを作成します。

コントローラーを編集する

app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController

    def new

    end

    def create
        @user = User.find_by(email: params[:email])

        if @user.present?
            #メールを送る
            PasswordResetMailer.with(user: @user).reset.deliver_later
            flash[:success] = "リクエストありがとうございます。パスワードリセットメールを送りました。"
            redirect_to root_path
        else
            flash[:danger] = "メールアドレスを見つかりませんでした。ご確認お願いします。"
            render :new
        end
    end
end

Mailerを設定する

app/mailer/password_reset_mailer.rb
class PasswordResetMailer < ApplicationMailer
...
    def reset
        @user = params[:user]
        @token = params[:user].signed_id(purpose: "password_reset", expires_in: 15.minutes)
        mail to: @user.email, subject: 'パスワードリセット申請ありがとうございます'
    end
end

ユーザを暗号化するためにsigned_idメソッドを使ってトークンを生成します。

signed_idメソッドは、Rails 6.1以降で導入された暗号化されたID(Signed ID)を生成するためのメソッドです。このメソッドを使用すると、URLパラメーターや一時的なトークンなど、セキュリティ上のリンクや識別子に使用するためにIDを安全にエンコードできます。

signed_idメソッドは、Active SupportライブラリのMessageVerifierクラスを使用してIDを署名します。これにより、IDが変更されていないことを検証できます。また、IDの内容は暗号化されるため、一般のユーザーには理解しにくい形式で表示されます。

signed_idメソッドは、セキュリティ上のリンクや識別子を作成する際に有用です。例えば、一時的なトークンの生成や、ユーザーのプライベートな情報をURLに埋め込む場合などに利用できます。

こちらの例を参考してパスワードリセットリンクにトークンの埋め込みと15分の有効期間を設定します。

https://runebook.dev/ja/docs/rails/activerecord/signedid/classmethods
https://github.com/rails/globalid

コンソールでsigned_idメソッドを試してみます。
signed_idメソッドを使ってid=9のユーザ(User.last)を暗号化してみます。
signed_idに条件を追加すると、生成されたトークンが長くなりましたね。
パーパスを指定するとサーバーがトークンの情報を読み取る時にpassword_reset用であるかを確認しますのでより安全性を向上させることができます。

$ rails c
Running via Spring preloader in process 95790
Loading development environment (Rails 6.1.7.3)
irb(main):001:0> user = User.last
   (0.6ms)  SELECT sqlite_version(*)
  User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 9, user_name: "user", email: "test@user.com", password_digest: [FILTERED], created_at: "2023-06-15 11:25:28.563906000 +0000", updated_at...
irb(main):002:0> user.signed_id
=> "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik9RPT0iLCJleHAiOm51bGwsInB1ciI6InVzZXIifX0=--6ac6fd7c35a7a62081c54fcb7b514ff0fb879202421c6bf3e683172f3bb2a9ec"
irb(main):003:0> user.to_global_id
=> #<GlobalID:0x00007f9e8f050070 @uri=#<URI::GID gid://rails-test/User/9>>
irb(main):005:0> user.to_global_id.to_s
=> "gid://rails-test/User/9"
irb(main):006:0> user.signed_id(expires_in: 15.minutes)
=> "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik9RPT0iLCJleHAiOiIyMDIzLTA2LTE3VDExOjM0OjIzLjg3MloiLCJwdXIiOiJ1c2VyIn19--63b0fd7d96b2ea182c13dd868fb0df36110fe13c3700038d9728f699e7858d06"
irb(main):007:0> user.signed_id(expires_in: 15.minutes, purpose: "password_reset")
=> "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik9RPT0iLCJleHAiOiIyMDIzLTA2LTE3VDExOjM1OjE4Ljk1NFoiLCJwdXIiOiJ1c2VyL3Bhc3N3b3JkX3Jlc2V0In19--f602529cca495ab4ecd394ce67a1f905b2b1c374852a390e337aa3ae8e2a35b9"

リクエスト送る用のルーティングを設定しましたが、リセット用のルーティングも追加していきます。

routes.rbを編集する

config/routes.rb
Rails.application.routes.draw do
...
  get "password/reset/edit", to: "password_resets#edit"
  patch "password/reset/edit", to: "password_resets#update"
end

リセット用のアクションを作成する

app/controllers/passwords_resets_controller.rb
class PasswordResetsController < ApplicationController
...
    def edit
        @user = User.find_signed!(params[:token], purpose: "password_reset")
    end
    
    def update
        if @user.update(password_params)
            flash[:success] = "パスワードをリセットされました。ログインしてください。"
        else
            render :edit
        end
    end

    private

    def password_params
        params.require(:user).permit(:password, :password_confirmation)
    end
end

リセット用のビューを作成する

urlのパラメーターにtokenを追加します。

app/views/password_rests/edit.html.erb
<%= form_with model: @user, url: password_reset_edit_path(token: params[:token]) do |form| %>
  <% if form.object.errors.any? %>
    <div class="alert alert-danger">
      <% form.object.errors.full_messages.each do |message|  %>
        <div>
          <%= message %>
        </div>
      <% end  %>
    </div>
  <% end %>
  <div class="form-group">
    <%= form.label :password %>
    <%= form.password_field :password, placeholder: "Enter your new password", class: "form-control mb-3"  %>
  </div>
  <div class="form-group">
    <%= form.label :password_confirmation %>
    <%= form.password_field :password_confirmation, placeholder: "パスワードをもう一度入力してください", class: "form-control mb-3"  %>
  </div>
  <%= form.submit "パスワードをリセットする", class:'btn btn-primary' %>
<% end %>

Mailerのビューを作成する

メールからサイトへ移動されるためpathではなくurlを使います。

app/views/password_rest_mailer/reset.text.rb
<%= @user.user_name %>様、
パスワードリセットのご申請ありがとございます。
下のリンクをクリックし、パスワードをリセットする画面が表示されますので、新しいパスワードを設定してください。
※パスワードリセットはメール受信後から「15分以内」に行ってください。
<%= password_reset_edit_url(token: @token) %>
app/views/password_rest_mailer/reset.html.rb
<!DOCTYPE html>
<html>
  <head>
    <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
  </head>
  <body>
    <p><%= @user.user_name %>様</p>
    <p>
      パスワードリセットのご申請ありがとございます。
      下のリンクをクリックし、パスワードをリセットする画面が表示されますので、新しいパスワードを設定してください。
      ※パスワードリセットはメール受信後から「15分以内」に行ってください。
    </p>
    <p><%= link_to "パスワードをリセットする", password_reset_edit_url(token: @token) %></p>
  </body>
</html>

letter_opener_webを使う

letter_opener_webGemを使ってメールを確認していきます。

Gemfile
group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'letter_opener_web'
end
bundle install

config/environments/development.rbにホストを追加する

メーラーのインスタンスは、サーバーが受信するHTTPリクエストのコンテキストと関係がないため、アプリケーションのホスト情報をメーラー内で使いたい場合は:hostパラメータを明示的に指定する必要があります。
デリバリーメソッドに先にインストールしたGemを記述して有効化にします。

config/environments/development.rb
config.action_mailer.delivery_method = :letter_opener_web
config.action_mailer.default_url_options = { host: 'localhost:3000' }

routes.rbにメール用のURLを追加する

config/routes.rb
mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development?

ここまで完了しましたらサーバーを一度再起動します。
localhost:3000/letter_openerにアクセスし、メールを送信されたことを確認します。

editアクションを編集する

15分間を過ぎてURLをアクセスするとエラーが発生しますのでeditアクションに処理を追加し、ユーザに再度リクエストを送るようにします。

app/controllers/passwords_resets_controller.rb
class PasswordResetsController < ApplicationController
    def edit
        @user = User.find_signed!(params[:token], purpose: "password_reset")
        rescue ActiveSupport::MessageVerifier::InvalidSignature
            flash[:danger] = "URLの有効期限が切れています。もう一度リクエストお願いします。"
            redirect_to password_reset_path
    end

    def update
        @user = User.find_signed!(params[:token], purpose: "password_reset")
        if @user.update(password_params)
            flash[:success] = "パスワードをリセットされました。ログインしてください。"
	    redirect_to login_path
        else
            render :edit
        end
    end

    private

    def password_params
        params.require(:user).permit(:password, :password_confirmation)
    end
end

ログからパスワードリセット機能の仕組を確認します。

Started PATCH "/password/reset/edit?token=[FILTERED]" for ::1 at 2023-06-17 22:55:05 +0900
Processing by PasswordResetsController#update as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Update password", "token"=>"[FILTERED]"}
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/password_resets_controller.rb:29:in `update'
  TRANSACTION (0.1ms)  begin transaction
  ↳ app/controllers/password_resets_controller.rb:30:in `update'
  User Exists? (0.1ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = ? AND "users"."id" != ? LIMIT ?  [["email", "user@sample.com"], ["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/password_resets_controller.rb:30:in `update'
  User Update (0.5ms)  UPDATE "users" SET "password_digest" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["password_digest", "$2a$12$ZeSWmeUr0yrPe3DfLgFma.SlRIRJZP5gwlvaJ4MAz8bFYfub.9jzq"], ["updated_at", "2023-06-17 13:55:06.142414"], ["id", 1]]
  ↳ app/controllers/password_resets_controller.rb:30:in `update'
  TRANSACTION (0.8ms)  commit transaction
  ↳ app/controllers/password_resets_controller.rb:30:in `update'
No template found for PasswordResetsController#update, rendering head :no_content
Completed 204 No Content in 220ms (ActiveRecord: 1.6ms | Allocations: 6188)


Started GET "/login" for ::1 at 2023-06-17 22:56:57 +0900
Processing by SessionsController#new as HTML
  Rendering layout layouts/application.html.erb
  Rendering sessions/new.html.erb within layouts/application
  Rendered sessions/new.html.erb within layouts/application (Duration: 2.9ms | Allocations: 2548)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered shared/_header.html.erb (Duration: 1.1ms | Allocations: 506)
  Rendered shared/_message.html.erb (Duration: 0.3ms | Allocations: 179)
  Rendered layout layouts/application.html.erb (Duration: 16.1ms | Allocations: 9530)
Completed 200 OK in 20ms (Views: 19.4ms | Allocations: 10856)

終わりに

パスワードリセットにdeviseを使うことが多いですが使わない場合の実装も楽しかったです。

https://railsguides.jp/action_mailer_basics.html

Discussion