[Rails] SorceryのReset passwordとletter_opener_webの導入

学習記録

はじめに

sorceryのReset Passwordモジュールを使用して、パスワードのリセット機能を実装していきましょう!
開発環境ではletter_opener_webを使用します。

基本的な実装方法は公式wikiを参考にしましょう。

作業の流れ

  • sorceryのReset passwordモジュールをインストールする
  • メイラーの作成
  • コントローラを作成
  • ビューの作成
  • letter_opener_webを追加
  • configの導入

sorceryのReset passwordモジュールをインストールする

手始めにsorceryのReset passwordモジュールをインストールするためのコマンドを入力します。

$ rails g sorcery:install reset_password --only-submodules

生成されたファイルを見ます。

> rails g sorcery:install reset_password --only-submodules          
Running via Spring preloader in process 71125
        gsub  config/initializers/sorcery.rb
      insert  app/models/user.rb
      create  db/migrate/20211009040003_sorcery_reset_password.rb

config/initializers/sorcery.rb を確認

Rails.application.config.sorcery.submodules = [:reset_password]

生成されたマイグレーションファイルを確認

class SorceryResetPassword < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :reset_password_token, :string, default: nil
    add_column :users, :reset_password_token_expires_at, :datetime, default: nil
    add_column :users, :reset_password_email_sent_at, :datetime, default: nil
    add_column :users, :access_count_to_reset_password_page, :integer, default: 0

    add_index :users, :reset_password_token
  end
end

reset_password_token に関するカラムがいくつか追記されています。

確認できたらマイグレーションしましょう。

$ rails db:migrate

メイラーの作成

UserMailer という名前でパスワードリセット用のメイラーを作成しましょう

$ rails g mailer UserMailer reset_password_email

生成されたファイルを見ます

> rails g mailer UserMailer reset_password_email                                                           
Running via Spring preloader in process 71280
      create  app/mailers/user_mailer.rb
      invoke  erb
      create    app/views/user_mailer
      create    app/views/user_mailer/reset_password_email.text.erb
      create    app/views/user_mailer/reset_password_email.html.erb

/mailers/user_mailer.rb が生成されており、

user_mailer の下に2つのビューファイルが生成されていますね。こちらは実際にユーザに対して送るメールの中身となるファイルです。テキスト形式とHTML形式になります。ユーザによってはHTML形式のメールを受け取りたくない人もいるので、テキストメールも作成しておきます。


パスワードリセットに使用するメイラーの定義

では次に、config/initializers/sorcery.rb にreset_passwordサブモジュールを追加し、パスワードリセットに使用するActionMailerとしてUserMailerを定義する記述をしましょう。

Rails.application.config.sorcery.submodules = [:reset_password, blabla, blablu, ...]

Rails.application.config.sorcery.configure do |config|
  config.user_config do |user|
    user.reset_password_mailer = UserMailer
  end
end

このファイルは非常に長いファイルですが、よく見てみると必要な記述がコメントアウトされている中で既に揃っています。必要な記述を探してコメントアウトを外して使用しましょう。

今回必要な記述は、395行目の # user.reset_password_mailer = の部分にUserMailerを付け加えます。

メイラービューの作成

先程生成された2つのメイラービューファイルの中身を作成しましょう。

<%= @user.decorate.full_name %>様
===========================================

パスワード再発行のご依頼を受け付けました。
こちらのリンクからパスワードの再発行を行ってください。
<%= @url %>
<%= @user.decorate.full_name %>様

<p>パスワード再発行のご依頼を受け付けました。</p>

<p>以下のリンクからパスワードの再発行を行ってください。</p>

<p><a href="<%= @url %>"><%= @url %></a></p>

上記の@url の中身は、tokenで発行された再登録用のurlが入っています。

メールを送信するメソッドを記述

作成したメールを送信するためのメソッドを記述します。

class UserMailer < ApplicationMailer
  default from: 'from@example.com'
  def reset_password_email
    @user = User.find(user.id)
    @url = edit_password_reset_url(@user.reset_password_token)
    mail(to: user.email, subject: 'パスワードリセット')
  end
end
  • default from: 'from@example.com'でメールの送信元のアドレスを指定できる
  • mail(to: user.email,subject: 'パスワードリセット')でメールの宛先、件名を指定

こちらの@user@url はどこで使われているのかというと、先ほど作成したメイラービューの中で使われています。

コントローラを作成

パスワードリセットを処理するコントローラーが必要なのでジェネレートコマンドで生成します。

$ rails g controller PasswordResets new create edit update
> rails g controller PasswordResets new create edit update                                                   
Running via Spring preloader in process 71542
      create  app/controllers/password_resets_controller.rb
      invoke  erb
      create    app/views/password_resets
      create    app/views/password_resets/new.html.erb
      create    app/views/password_resets/create.html.erb
      create    app/views/password_resets/edit.html.erb
      create    app/views/password_resets/update.html.erb
      invoke  decorator
      create    app/decorators/password_reset_decorator.rb

生成されたPasswordResetsコントローラの中身を記述する

公式wikiを参考にし、記述しましょう。

class PasswordResetsController < ApplicationController
  # Rails 5以降では、次の場合にエラーが発生します。
  # before_action :require_login
  # ApplicationControllerで宣言されていません。
  skip_before_action :require_login
  
  # パスワードリセット申請画面へレンダリングするアクション
  def new; end
    
  # パスワードのリセットを要求するアクション。
  # ユーザーがパスワードのリセットフォームにメールアドレスを入力して送信すると、このアクションが実行される。
  def create 
    @user = User.find_by(email: params[:email])
        
    # この行は、パスワード(ランダムトークンを含むURL)をリセットする方法を説明した電子メールをユーザーに送信します
    @user&.deliver_reset_password_instructions!
    # 上記は@user.deliver_reset_password_instructions! if @user と同じ
        
    # 電子メールが見つかったかどうかに関係なく、ユーザーの指示が送信されたことをユーザーに伝えます。
    # これは、システムに存在する電子メールに関する情報を攻撃者に漏らさないためです。
    redirect_to login_path, success: t('.success')
  end
    
  # パスワードのリセットフォーム画面へ遷移するアクション
  def edit
    @token = params[:id]
    @user = User.load_from_reset_password_token(params[:id])
  return not_authenticated if @user.blank?
  end
      
  # ユーザーがパスワードのリセットフォームを送信したときに発生
  def update
    @token = params[:id]
    @user = User.load_from_reset_password_token(params[:id])
    return not_authenticated if @user.blank?

    # 次の行は、パスワード確認の検証を機能させます
    @user.password_confirmation = params[:user][:password_confirmation]
    # 次の行は一時トークンをクリアし、パスワードを更新します
    if @user.change_password(params[:user][:password])
      redirect_to login_path, success: t('.success')
    else
      flash.now[:danger] = t('.fail')
      render :edit
    end
  end
end

パスワードリセット申請時にはログインページにリダイレクトされるようにしましょう。

コードを紐解く

deliver_reset_password_instructions!メソッド

有効期限付きのリセットコード(トークン)を生成し、ユーザーに電子メールを送信します。

# Generates a reset code with expiration and sends an email to the user.
def deliver_reset_password_instructions!
  mail = false
  config = sorcery_config
  # hammering protection
  return false if config.reset_password_time_between_emails.present? && send(config.reset_password_email_sent_at_attribute_name) && send(config.reset_password_email_sent_at_attribute_name) > config.reset_password_time_between_emails.seconds.ago.utc

  self.class.sorcery_adapter.transaction do
    generate_reset_password_token!
    mail = send_reset_password_email! unless config.reset_password_mailer_disabled
  end
  mail
end
https://github.com/Sorcery/sorcery/blob/6fdc703416b3ff8d05708b05d5a8228ab39032a5/lib/sorcery/model/submodules/reset_password.rb#L98

また、@user&.deliver_reset_password_instructions! ですが、&.はメソッドの実行対象のオブジェクトがnil(ユーザーがいなかったとき)だった場合を考慮した記法です。

@user = User.find_by(email: params[:email])でユーザーが見つからなかったときは@userにはnilが入ります。そのnilに対してdeliver_reset_password_instructions!メソッドを実行すると、UndefinedMethodの例外が発生します。

そこで&.を使用すれば、もしnilであってもnilを返すだけになり、例外は発生しません。

load_from_reset_password_tokenメソッド

トークンでユーザーを検索し、有効期限もチェックします。 トークンが見つかり、有効な場合、ユーザーを返します。

# Find user by token, also checks for expiration.
# Returns the user if token found and is valid.
def load_from_reset_password_token(token, &block)
  load_from_token(
    token,
    @sorcery_config.reset_password_token_attribute_name,
    @sorcery_config.reset_password_token_expires_at_attribute_name,
    &block
  )
end
https://github.com/Sorcery/sorcery/blob/6fdc703416b3ff8d05708b05d5a8228ab39032a5/lib/sorcery/model/submodules/reset_password.rb#L62

また、User.load_from_reset_password_token(params[:id])にて、有効期限切れなどでユーザーが取得できなかったとき、not_authenticatedとなります。

change_passwordメソッド

トークンをクリアし、ユーザーの新しいパスワードを更新しようとします。

# Clears token and tries to update the new password for the user.
def change_password(new_password, raise_on_failure: false)
  clear_reset_password_token
  send(:"#{sorcery_config.password_attribute_name}=", new_password)
  sorcery_adapter.save raise_on_failure: raise_on_failure
end
https://github.com/Sorcery/sorcery/blob/6fdc703416b3ff8d05708b05d5a8228ab39032a5/lib/sorcery/model/submodules/reset_password.rb#L126

PasswordResetsコントローラのルーティングを設定

resources :password_resets, only: %i[new create edit update]

トークンにユニーク制約を付与

Userモデルにバリデーションを追記します。

tokenは一意なものでなければいけませんので、uniqueness: true を付与します。

しかし、パスワードを変更した際、reset_password_token はnilになるのでユニーク制約に引っかかってしまいます。nilを許可する必要があるでしょう。

そこでallow_nil オプションを使います。allow_nil: true を付与することで、対象の値がnilの場合にバリデーションをスキップします。

validates :reset_password_token, presence: true, uniqueness: true, allow_nil: true

ビューファイルの作成

パスワードリセット申請画面

<%= content_for(:title, t('.title')) %>
<div class="container">
  <div class="row">
    <div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
      <h1><%= t '.title' %></h1>
      <%= form_with url: password_resets_path, local: true do |f| %>
        <div class="form-group">
          <%= f.label :email, User.human_attribute_name(:email) %><br />
          <%= f.email_field :email, class: 'form-control' %>
        </div>
        <%= f.submit t('password_resets.new.submit'),class: 'btn btn-primary' %>
      <% end %>
    </div>
  </div>
</div>

こちらのform_with はモデルと紐付いていない点に注意しましょう。また、そのことよりラベルが日本語化しませんので、自分で日本語化する記述を追加しましょう。

パスワードリセット画面

<%= content_for(:title, t('.title')) %>
<div class="container">
  <div class="row">
    <div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
      <h1><%= t '.title' %></h1>
      <%= form_with model: @user, url: password_reset_path(@token), local: true do |f| %>
        <%= render 'shared/error_messages', object: f.object %>

        <div class="form-group">
          <%= f.label :email %>
          <%= @user.email %>
        </div>
        <div class="form-group">
          <%= f.label :password %>
          <%= f.password_field :password, class: 'form-control' %>
        </div>
        <div class="form-group">
          <%= f.label :password_confirmation %>
          <%= f.password_field :password_confirmation, class: 'form-control' %>
        </div>
        <div class="actions">
          <p class="text-center">
          <%= f.submit class: 'btn btn-primary' %>
          </p>
        </div>
      <% end %>
    </div>
  </div>
</div>

パスワードリセット申請画面リンクの表示

ログイン画面にパスワードリセット申請画面へのリンクを追加しましょう。

      <% end %>
      <div class='text-center'>
        <%= link_to (t '.to_register_page'), new_user_path %>
        <%= link_to t('.password_forget'), new_password_reset_path %>
      </div>
    </div>
  </div>

letter_opener_webを追加

letter_opener_web を使って開発環境では実際のメールは送られないように設定しましょう。

Gemfileにletter_opener_webを追加する

まず、gemを開発環境に追加し、bundleコマンドを実行してインストールします。

group :development do
  gem 'letter_opener_web', '~> 1.0'
end
$ bundle install

ルーティングに追記

LetterOpenerWebにアクセスするために必要な記述を追記しましょう

mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
config.action_mailer.delivery_method = :letter_opener_web
config.action_mailer.default_url_options = { host: 'localhost:3000' }

メールが送られたか確認する

こちらのURLにアクセスしてメールが送られてきているか確認してみましょう。

http://localhost:3000/letter_opener

configの導入

Configは環境固有の設定を簡単で使いやすい方法で管理できるようになるgemです。

host情報はconfigというgemを使ってsettings/development.ymlに記載する

まずGemfile に追記しましょう。

gem 'config'
$ bundle install

config を導入しましたら、こちらのコマンドを入力しましょう。

$ rails g config:install
> rails g config:install                                                                                      ?21_password_reset
Running via Spring preloader in process 78325
      create  config/initializers/config.rb
      create  config/settings.yml
      create  config/settings.local.yml
      create  config/settings
      create  config/settings/development.yml
      create  config/settings/production.yml
      create  config/settings/test.yml
      append  .gitignore

これにより、カスタマイズ可能な構成ファイルconfig/initializers/config.rbと一連のデフォルト設定ファイルが生成されます。

config/initializers/config.rbconfigの設定ファイル
config/settings.ymlすべての環境で利用する定数を定義
config/settings.local.ymlローカル環境のみで利用する定数を定義
config/settings/development.yml開発環境のみで利用する定数を定義
config/settings/production.yml本番環境のみで利用する定数を定義
config/settings/test.ymlテスト環境のみで利用する定数を定義

host情報をsettings/development.ymlに記載しましょう。

default_url_options:
  host: 'localhost:3000'
config.action_mailer.perform_caching = false
config.action_mailer.default_url_options = Settings.default_url_options.to_h
config.action_mailer.delivery_method = :letter_opener_web

本番環境では以下のようにします。host: 'example.com'の部分には自分のドメインを設定します。

default_url_options:
  protcol: 'https'
  host: 'example.com'
config.action_mailer.default_url_options = Settings.default_url_options.to_h

終わりに

公式wikiの通りに行うことである程度実装することができますが、しっかり手順を把握しておきましょう。

参考記事

Action Mailer の基礎 - Railsガイド
Action Mailerを使用してメールを送受信する方法について解説します。
404 Not Found - Qiita - Qiita
【Rails】パスワード変更(トークンがどのように使用されているのか) - Qiita
はじめにsorceryのパスワードリセット機能の実装を行いました。以下3点について記述しましたので、ご興味あれば見てってください。1. sorceryのパスワードリセット機能の実装方法2. …
GitHub - rubyconfig/config: Easiest way to add multi-environment yaml settings to Rails, Sinatra, Pandrino and other Ruby projects.
Easiest way to add multi-environment yaml settings to Rails, Sinatra, Pandrino and other Ruby projects. - GitHub - rubyconfig/config: Easiest way to add multi-e...
Reset password
Magical Authentication. Contribute to Sorcery/sorcery development by creating an account on GitHub.
GitHub - fgrehm/letter_opener_web: A web interface for browsing Ruby on Rails sent emails
A web interface for browsing Ruby on Rails sent emails - GitHub - fgrehm/letter_opener_web: A web interface for browsing Ruby on Rails sent emails

コメント

タイトルとURLをコピーしました