【豆知識】リセットパスワードの流れを改めて整理してみる

# はじめに
アプリ制作の課題に取り組んでいる中で、改めて過去に行った課題を参考にしていたら流れが理解できていない状態。改めて自分が何をしているのか、コードの意味を含め一度整理しておくためにもリセットパスワードの全体の流れをアウトプットしたいと思います。

## 環境
- Windows, WSL
- Docker
- Ruby 3.2.3
- Rails 7.1.3


## 1.gem 'letter_opener_web'の設定（ http://localhost:3000/letter_opener）

https://github.com/fgrehm/letter_opener_web

- Gemfileに追記する（開発環境！）
  ```ruby
  group :development do
    gem 'letter_opener_web', '~> 3.0'
  end
  ```

- コンテナ内に入りインストールを行う
  ```ruby
  $ docker exec コンテナ名 bash
  # bundle install

  インストール後はサーバーを立ち上げなおす（この過程を省くと変なエラーが起こる可能性あり）
  $ docker compose restart

  Dockerコンテナ内で開発環境も起動しておく
  $ docker compose exec web bin/dev 
  ```

- config/routes.rbに追記
  ・開発環境 (Rails.env.development? が true の場合) において、/letter_opener というURLで Letter Opener Web のインターフェースにアクセスできるようになります。これにより、送信されたメールの内容をブラウザ上で確認できます。
  ```ruby
  Your::Application.routes.draw do
   mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
  end
  ```


- config/environments/development.rb の設定
  ・1つめはメールの配信方法 (delivery_method) を letter_opener_web に指定しています。
  ・2つ目については Railsの`ActionMailer`では、メール内に含めるURLを生成する際に`default_url_options`で指定されたホスト名とポート番号が使用されますが、この設定がないことでリンク生成が失敗してしまいます。また、パスワードリセットのメールで`edit_password_reset_url(@user.reset_token)`のようにURLを生成する場合、ホスト名が設定されていないとリンクが正しく構築されません。その結果、ユーザーがリンクをクリックしても期待するページにアクセスできず、エラーが発生する可能性があります。
  ```ruby
  # letter_opener_web の設定
  config.action_mailer.delivery_method = :letter_opener_web

  # メーラーでのホスト情報
  config.action_mailer.default_url_options = { host: 'localhost:3000' }
  ```


- letters_location の設定について
  ・公式にはconfig/environments/development.rbに次の設定をすることも提案されていましたが、開発環境はデフォルトを使用すればいいのでこちらの設定はしませんでした。このコードを追加することで、手紙の保存場所を変更できます
  ```ruby
  LetterOpenerWeb.configure do |config|
   config.letters_location = Rails.root.join('your', 'new', 'path')
  end
  ```
  
<br>

## 2.パスワードリセット

https://github.com/Sorcery/sorcery/wiki/Reset-password

- Sorcery のサブモジュール、reset_passwordの導入
  ・ユーザーがパスワードを忘れた場合に、再設定するための手続きを提供する機能を実装。
  ・`rails g sorcery`だとSorceryの全ての標準機能をインストールしてしまうが`:install reset_password --only-submodules`を加えることでreset_passwordに必要なファイルだけが生成される。
  ```ruby
  $ docker exec コンテナ名 bash
  # rails g sorcery:install reset_password --only-submodules
  ```

- 生成されたマイグレーションファイルに追記する(reset_password_tokenカラム)
  ・changeメソッドの中に書かれた内容が、データベースに対して行われる変更を定義し、メソッド内のコードが実行されると、データベースが更新される。
  ・add_columnメソッドを使って、usersテーブルに新しいカラムを追加
  ・それぞれのカラムは上から順に、「パスワードリセット用のトークン・トークンの有効期限・パスワードリセット用のメールが送信された日時・パスワードリセットページにアクセスした回数」を保存するためのカラム
  ・`reset_password_token`カラムにインデックスを追加する事で、トークン[^1]の検索が速くなり、`unique: true`と指定してるから、同じトークンを持つレコードが2つ存在できないように制約がかかる
  ```ruby
  class SorceryResetPassword < ActiveRecord::Migration
    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, unique: true
    end
  end
  ```

- マイグレーションファイルに追記後以下実行
  ```ruby
  # rails db:migrate
  ```

<br>

## 3.メールアクションを1つ持つメイラーを追加
- Railsのメール機能をサポートするためのファイルを生成する
 ```ruby
 # rails g mailer UserMailer reset_password_email
 ```

- app/mailers/user_mailer.rbファイルに`user`パラメータを追加他
  ・user は、メールを送信する対象となるユーザーを指します。
  ・@user というインスタンス変数を作成し、user.id を使ってデータベースからそのユーザーを取得
  ・@url というインスタンス変数に、パスワードリセットページへのリンクを生成し、そのURLを代入
  ・mail メソッドは、メールを送信するために使用されます。to: user.email でメールの送信先を指定し、subject: "件名" でメールの件名を設定
  ```ruby
  class UserMailer < ApplicationMailer
    default from: 'no-reply@アプリ名.com'

    def reset_password_email(user)
      @user = User.find(user.id)
      @url  = edit_password_reset_url(@user.reset_password_token)
      mail(to: user.email, subject: "パスワードリセット再設定のご案内")
    end
  end
  ```
  
<br>

## 4.`reset_password`サブモジュールを追加し、使用するメイラーを定義
- Sorceryの初期設定ファイルの編集(config/initializers/sorcery.rbファイル)
  ・UserMailer を使用してリセットメールを送信するように構成していく。
  ・コメントアウトを外して定義していく。
  ・`config`という設定オブジェクトを使い`Sorcery`の動作をカスタマイズしていく
  ・`user_config`ブロック内では、個々のユーザーに対する認証の動作をカスタマイズ。ここで設定された内容は、アプリケーションの全ユーザーに適用‼
  ・`stretches`は、パスワードのハッシュ化[^2]の回数を指定
  ・`authentications_class`を使うことで、ユーザーの認証に関する処理を柔軟に管理できるようになるが不要なら削除。
  ※外部認証機能を使わない場合、`external`は外してください。`blablu`以降はご自身が使用する機能など。
  ```ruby
  Rails.application.config.sorcery.submodules = [:reset_password, :external, blablu, ...]

  Rails.application.config.sorcery.configure do |config|
    config.user_config do |user|
      user.stretches = 1 if Rails.env.test?
      user.reset_password_mailer = UserMailer
      user.authentications_class = Authentication
    end
  end
  ```

<br>

## 5.Userモデルにバリテーション追加
・`validates`はRailsのバリデーションメソッドで、モデルの属性に対して様々な条件を設定するために使う
・`uniqueness: true`によって同じトークンが存在する場合トークンの再利用を防ぐ
・`allow_nil: true`により、リセットパスワードトークンが`nil`の場合エラー発生を防ぐ。
```ruby
validates :reset_password_token, uniqueness: true, allow_nil: true
```

<br>

## 6.パスワードアクションを処理するコントローラの作成
- コンテナ内で下記コマンド実行
  ```ruby
  # rails g controller PasswordResets create edit update
  ```
  
- `app/controllers/password_resets_controller.rb`編集
  ```ruby
  class PasswordResetsController < ApplicationController
    skip_before_action :require_login

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

      @user.deliver_reset_password_instructions! if @user

      redirect_to(root_path, :notice => 'Instructions have been sent to your email.')
    end

    def edit
      @token = params[:id]
      @user = User.load_from_reset_password_token(params[:id])

      if @user.blank?
        not_authenticated
        return
      end
    end

    def update
      @token = params[:id]
      @user = User.load_from_reset_password_token(params[:id])

      if @user.blank?
        not_authenticated
        return
      end

      @user.password_confirmation = params[:user][:password_confirmation]

      if @user.change_password(params[:user][:password])
        redirect_to(root_path, :notice => 'Password was successfully updated.')
      else
        render :action => "edit"
      end
    end
  end
  ```

  ※上記コードは資料参考のものだが次の通りパラメータを用いたりエラー文を表示するよう変更も可能
  ```ruby
  class PasswordResetsController < ApplicationController
    # ログインできないユーザー用
    skip_before_action :require_login

    # パスワードリセット申請フォーム
    def new; end

    # ユーザーが入力したメールアドレスを取得
    def create
      # メールアドレスからユーザーを特定
      @user = User.find_by(email: params[:email])
      if @user
        # ユーザが見つかれば、トークンを生成し、再設定用のメールを送信
       @user&.deliver_reset_password_instructions!
        redirect_to login_path, notice: 'メールを送信しました'
      else
        flash.now[:alert] = 'メールアドレスが見つかりません'
       render :new, status: :unprocessable_entity
      end
    end

    # パスワードリセットフォーム
    def edit
      @token = params[:id]
      @user = User.load_from_reset_password_token(params[:id])

      # ユーザーがいなければログインページへ遷移
      if @user.blank?
        not_authenticated
        return
      end
    end

    def update
      @token = params[:id]
      # params に含まれるトークンからユーザを特定
      @user = User.load_from_reset_password_token(params[:id])

      # ユーザが見つからない場合
      if @user.blank?
        # ログイン画面に遷移
        not_authenticated
        return
      end

      # バリデーションチェック
      @user.password_confirmation = password_params[:password_confirmation]
      # トークンをクリアして、パスワードを更新
      if @user.change_password(password_params[:password])
        # ログインページに遷移し、フラッシュメッセージを表示
        redirect_to login_path, notice: 'パスワードを再設定しました'
      else
        # 再設定画面を再表示
        flash.now[:alert] = '再設定に失敗しました'
        render :edit, status: :unprocessable_entity
      end
    end

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

<br>

## 7.残りを追加
- ルーティングの設定
  ・`password_resets`コントローラーの各アクション等に対するルート設定をすることで、パスワードリセットのリクエストやフォーム表示、リセット処理が行えるようになる。
  ```ruby
  # パスワードリセット
  resources :password_resets, only: %i[new create edit update]
  ```

- app/views/layouts/mailer.html.erbファイルについて
  ・資料には`= yield`と記載されいていますが。特に修正は不要かと。

- app/views/user_mailer/reset_password_email.text.erb設定
  ・パスワードリセットのメールテンプレートです。ユーザーのメールアドレスとリセット用のリンクが含まれています。
  ```ruby
  <%= @user.name %>様
  ===============================================

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

  こちらのリンクからパスワードの再発行を行ってください。 <%= @url %>.
  ```

- app/views/user_mailer/reset_password_email.html.erb設定
  ・Rails の Action Mailer では、通常、両方のテンプレートを用意し、受信者のメールクライアントが対応している形式でメールを送信します。なので今回はこちらもコードを入力しておきます。
  ```ruby
  <p><%= @user.name %>様</p>
  <p>===============================================</p>

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

  <p>こちらのリンクからパスワードの再発行を行ってください。</p>
  <p><a href="<%= @url %>"><%= @url %></a></p>
  ```

<br>

## 8.パスワードリセット用ビューの設定（editとnewに関しては資料をもとに自身が好きなフォームにアレンジ）
-  app/views/password_resets/edit.html.erb
  ・ユーザーが新しいパスワードを入力するフォームです。トークンを使ってリセットリンクからユーザーを特定し、新しいパスワードを設定します。

- app/views/user_sessions/new.html.erb
  ・ログインページに「パスワードを忘れた？」リンクを追加
  ```ruby
  <%= link_to 'Forgot Password?', new_password_reset_path %>
  ```

- app/views/password_resets/new.html.erb
  ・「新しい」ビューを作成し、ログインページからリンクを設定


## 9.gem 'config'の設定

https://github.com/rubyconfig/config

- gem 'config'とは、Railsアプリケーションで設定管理を簡単に行うためのgemです。
- 環境ごとに異なる設定を一元管理することができ、開発環境、テスト環境、本番環境で異なる設定値を使いたい場合に非常に便利です。
  ```ruby
  Gemfileに追記
  gem 'config'
  
  コンテナ内で以下コマンド実行してインストールしサーバー立ち上げなおす
  $ bundle install
  # docker compose restart
  # docker compose exec web bin/dev

  設定の初期化を行う為つぎのコマンドを実行する
  $ rails g config:install
  ```

- config/settings/development.yml例えばこんな感じで設定
  ```ruby
  default_url_options:
    host: 'localhost:3000'
  ```

- 上記ファイルで定義した値を呼び出すようにconfig/environments/development.rbを編集
  ・to_hは、オブジェクトをハッシュに変換するメソッドで、`config.action_mailer.default_url_options`がハッシュ形式のオプションを期待しているからつけないとエラーになってしまう。
  ```ruby
  config.action_mailer.default_url_options = Settings.default_url_options.to_h
  ```

**※config/配下を編集した場合は各種サーバーを立ち上げ直してください**

- .gitignoreを編集する
  ・特定のファイルやディレクトリをGitの管理から除外するため
  ・config/settings.local.ymlファイルなどには、データベースのパスワードやAPIキーなどの機密情報が含まれることが多いから、これらをGitに含めないようにする必要がある
  ・また、.local.ymlというファイル名は、開発環境やテスト環境専用の設定を含むことが一般的。これらは開発者ごとに異なるため、リポジトリに含めると混乱を招くことがある
  ```ruby
  config/settings.local.yml
  config/settings/*.local.yml
  config/environments/*.local.yml
  ```

# さいごに
ここまでごらんいただきありがとうございます。今回の記事が何か参考になればうれしい限りです。
また、誤字脱字など気づいた点等ございましたら、ご指摘いただけると幸いです。


[^1]: トークン：特定の情報を識別するための文字列やコードのこと
パスワードリセットトークン: ユーザーがパスワードをリセットするために送信されるリンクに含まれるコード。これを使って、リセット画面にアクセスできるようになる。

[^2]: ハッシュ化：パスワードを暗号化するプロセス。`stretches`の回数が増えると暗号化が強くなり、より安全になります。しかしテスト環境（Rails.env.test?）では、テストを速くするために1回に減らします。