[Rails]パスワードリセット 4/20
はじめに
前回の記事ではパスワードの更新機能を実装しました。
今回ではパスワードリセット機能を実装していきます。
ユーザがログイン画面のパスワードを忘れた
をクリックし、メールアドレスを入れてパスワードリセットをリクエストし、リセットリンクをメールに届くようにします。
パスワード更新
環境
Rails 6.1.7.3
ruby 3.0.0
流れ
1. パスワードリセットリクエスト用ルートを追加する
2. リセット用コントローラーを作成する
3. パスワードリセット用ビューを作成する
4. Mailerを作成する
5. パスワードリセット用ルートを追加する
6. リセット用アクションを作成する
7. Mailerの設定を追加する
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
class PasswordResetsController < ApplicationController
def new
end
def create
end
end
パスワードリセット用ビューを作成する
<%= 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 %>
ログインフォームにパスワードリセットリンクを追加する
<%= 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.erb
とreset.html.erb
を作成します。
コントローラーを編集する
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を設定する
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分の有効期間を設定します。
コンソールで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
を編集する
Rails.application.routes.draw do
...
get "password/reset/edit", to: "password_resets#edit"
patch "password/reset/edit", to: "password_resets#update"
end
リセット用のアクションを作成する
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
を追加します。
<%= 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
を使います。
<%= @user.user_name %>様、
パスワードリセットのご申請ありがとございます。
下のリンクをクリックし、パスワードをリセットする画面が表示されますので、新しいパスワードを設定してください。
※パスワードリセットはメール受信後から「15分以内」に行ってください。
<%= password_reset_edit_url(token: @token) %>
<!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_web
Gemを使ってメールを確認していきます。
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.action_mailer.delivery_method = :letter_opener_web
config.action_mailer.default_url_options = { host: 'localhost:3000' }
routes.rb
にメール用のURLを追加する
mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development?
ここまで完了しましたらサーバーを一度再起動します。
localhost:3000/letter_opener
にアクセスし、メールを送信されたことを確認します。
edit
アクションを編集する
15分間を過ぎてURLをアクセスするとエラーが発生しますのでedit
アクションに処理を追加し、ユーザに再度リクエストを送るようにします。
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を使うことが多いですが使わない場合の実装も楽しかったです。
Discussion