DeviseTokenAuth×AWS SESでパスワードリセット【Rails】

現在オンラインスクールにてプログラムの勉強をしているとぴ（[@topi_log](https://x.com/topi_log)）と申します。
DeviseTokenAuthを使ったアプリ開発でパスワードリセットを行ったので、そのまとめとして執筆いたしました。
初学者ゆえ、間違いなどありましたらそっと教えていただけますと幸いです。

# 対象者

- RailsAPIモードで、DeviseTokenAuthによる認証を行っている人
- フロントとバックは別ドメインで管理している人
- AWS SESを使ってパスワードリセットのメールを送りたい人
- 独自メインをCloudflareで作っている人
（Cloudflareでなくても同じように設定すればいけると思います）

# 開発環境

バックエンド
- Ruby on Rails7.1.3 APIモード
- Ruby3.2.3
- DeviseTokenAuth

フロントエンド
- Next.js14
※今回は設定とバックエンドを中心ですので実装内容は細かく書いていません。

インフラ
- Docker
- Vercel（フロントエンド）
- Render.com（バックエンド）
- Cloudflare（独自ドメイン）
- AWS SES（メールサーバー）

その他（ローカル環境）
- WSL2
- Ubuntu22.4

### 前提
新規登録やログイン・ログアウト周りはやりません。
あくまでパスワードリセットに焦点をあてて執筆しております。

# 実装手順
1. AWS SESの設定
1. Cloudflareの設定
1. バックエンド
1. フロントエンド
1. デプロイサーバーに環境変数設定

# 全体のフロー
フロントとバックの行き来があり混乱しやすいので一度整理します。

![パスワードリセッフロート.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3083959/1a02696d-5341-e08f-86e7-480152c90ca8.png)

①
　ユーザーはパスワード申請をするためにメールアドレスを入力する
②
　フロントからRailsへメールアドレスが送信される。
③
　RailsからAWS SESへパスワード再申請のメールをトークン付きで送信
④
　AWS SESからユーザーへトークン付きのパスワード再設定URLが載ったメールを送信
⑤
　ユーザーはURLからRailsへリダイレクト
⑥
　Railsはトークンをつけてフロントへリダイレクト
⑦
　ユーザーに再設定されたパスワードをRailsへ送信
　Railsはトークン情報を参照しパスワードを再設定。
⑧
　パスワード再設定の結果（パスワードの再設定ができましたなど）を送信

今回はフロントとバックを完全分離していることもありこのような流れで実装しました。

# 実装
それでは実装していきます。
## 1. AWS SESの設定
検索窓からSESを入力してSESを開きます。
[![Image from Gyazo](https://i.gyazo.com/5e60cd6d50f3ebabd255240c3c19e317.png)](https://gyazo.com/5e60cd6d50f3ebabd255240c3c19e317)
「使用を開始」ボタンを押します。

## 2. sandboxの設定解除とIAM設定
下記サイトの「sandboxから移動」項目を参考にsandbox環境を外します。
IAM設定も書かれておりますので参考にして設定します。

https://qiita.com/YutoYasunaga/items/212be2ce4b53575bafa7

ドメインの検証は最大72時間かかるそうです。私は1~2日かかりました。

## 2. Cloudflareの設定
SESの設定ができたらCloudflareにDNS設定をします。
独自ドメインは取得済みとします。

### 1. DNS設定
ダッシュボードからWebサイトのダッシュボードに飛びます。
左カラムの「Webサイト」を選択した後、取得したドメインのサイトが表示されるのでクリックします。
[![Image from Gyazo](https://i.gyazo.com/839195b2995864df51199d8e732fb5b7.png)](https://gyazo.com/839195b2995864df51199d8e732fb5b7)

サイトのダッシュボードになるので、左カラムから「DNS」⇨「レコード」をクリックします。
[![Image from Gyazo](https://i.gyazo.com/eb465775042de5510e8debc1a7a746d6.png)](https://gyazo.com/eb465775042de5510e8debc1a7a746d6)

レコード一覧が出てきます。最初はおそらく２つほどしか無いかと思います（もしかしたら１件もでないかもしれない）
画像赤枠の「レコードの追加」を押します。
[![Image from Gyazo](https://i.gyazo.com/98ac6c6f1fde072aa4d6f66daf636f64.png)](https://gyazo.com/98ac6c6f1fde072aa4d6f66daf636f64)

### 2. SESのレコードを取得
ドメインの検証が済んでる状態で行います。
SESの画面に飛びIDを選択します。
[![Image from Gyazo](https://i.gyazo.com/69277a23d2541c2158c7fc463c13def8.png)](https://gyazo.com/69277a23d2541c2158c7fc463c13def8)

画像は黒く伏せていますが、パスワードリセットの設定をしたいID（ドメイン）をクリックします。
[![Image from Gyazo](https://i.gyazo.com/c242d5b6c864c93f38c6647efc90f061.png)](https://gyazo.com/c242d5b6c864c93f38c6647efc90f061)

スクロールすると「DNSをレコードの発行」とあるのでトグルを開きます。
そこに3種類のDNSレコードがあるのでこちらを設定していきます。
[![Image from Gyazo](https://i.gyazo.com/f52eac33e0a96b57d2c6fed3e2f58b1d.png)](https://gyazo.com/f52eac33e0a96b57d2c6fed3e2f58b1d)

レコードを設定します。
[![Image from Gyazo](https://i.gyazo.com/95e2c0f2c1f19f5d8951d709ea9187bc.png)](https://gyazo.com/95e2c0f2c1f19f5d8951d709ea9187bc)
①：タイプは「CNAME」
②：名前はSESレコードの「名前」の部分
③：ターゲットはSESレコードの「数値」の部分
④：プロキシステータスはOFF
です。
記入したら保存ボタンを押します。この作業を3回繰り返し3つレコードが保存されればOKです。

## 3. バックエンド
まずはバックエンドの設定周りを行います。
### 0-a. Gemfileの確認
ここでGemfileの確認をしておきましょう。
ざっくり抜粋するとこんな感じです。
```ruby
# 本環境
group :production do
  # メールサーバー
  gem 'aws-sdk-rails', '~> 3'
end

# .envを使うため
gem 'dotenv-rails'
# フロントとバックを分けているので同じサイト判定させるためのgem
gem 'rails_same_site_cookie'
# 認証
gem "devise"
# トークンの方はRails7に対応していないので、githubから取得
gem 'devise_token_auth', '>= 1.2.0', git: 'https://github.com/lynndylanhurley/devise_token_auth'
# 多言語用
gem 'devise-i18n'
# CSRF対策用
gem "omniauth-rails_csrf_protection"
# 設定
gem 'config'
```

## 0-b. .env
.envの中身も確認しておきます
抜粋したものがこちら
```yaml
# フロントのURL
FRONT_URL=http://localhost:8000
# フロントのホスト
HOST=http://localhost

# AWSのIAMで設定した時に発行されるアクセスキー
AWS_ACCESS_KEY_ID=
# AWSのIAMで設定した時に発行されるシークレットアクセスキー
AWS_SECRET_ACCESS_KEY=
```
IAMで発行されるアクセスキーとシークレットアクセスキーの環境変数名は何でも良いのですが、最終的に本環境では`AWS_ACCESS_KEY_ID`と`AWS_SECRET_ACCESS_KEY`の名前でも設定しないと動かないので合わせておきます。

## 1. config/settings/production.yml

`gem config`を使っているのでそれを利用して設定します。

```yaml
default_url_options:
  host: <%= ENV['HOST'] %>
  protocol: https
```

## 2. config/enviroments/production.rb

```ruby
Rails.application.configure do
  # ==== #
  # 省略 #
  # ==== #
  
  # SESのクレデンシャルを生成
  credentials = Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'])
  # SESを使用する配信方法をActionMailerに追加
  Aws::Rails.add_action_mailer_delivery_method(
    :ses,                     # SESを指定
    credentials: credentials, # クレデンシャル設定
    region: 'ap-northeast-1'  # リージョン指定、今回は東京
  )
	
	# ActionMarilerのデフォルトのURL
	# 先ほど設定したSettingsを使って設定
  config.action_mailer.default_url_options = Settings.default_url_options.to_h
  
  # ActionMailerに配信方法をSESで設定
  config.action_mailer.delivery_method = :ses
  
  # 実際に配信するかどうか
  config.action_mailer.perform_deliveries = true
  
  # メーラービューをキャッシュする
  # 利用できるのはフラグメントキャッシュ
  config.action_mailer.perform_caching = false
  
  # メール配信に失敗した場合にエラーを発生させるか
  # Sentryなど監視ツールを導入しているとそこにもエラーが行くのでわかりやすい
  config.action_mailer.raise_delivery_errors = true
end
```

ActionMailerについてはRailsガイドが分かりやすいです。

https://railsguides.jp/action_mailer_basics.html

実装した項目は以下の通りです。
[2.7 Action MailerのビューでURLを生成する](https://railsguides.jp/action_mailer_basics.html#action-mailerのビューでurlを生成する)
[5.5 delivery_method](https://railsguides.jp/action_mailer_basics.html#delivery-method)
[5.6 perform_deliveries](https://railsguides.jp/action_mailer_basics.html#perform-deliveries)
[2.4.1 メーラービューをキャッシュする](https://railsguides.jp/action_mailer_basics.html#メーラービューをキャッシュする)
[5.4 raise_delivery_errors](https://railsguides.jp/action_mailer_basics.html#raise-delivery-errors)

## 3. config/initializers/devise_token_auth.rb
パスワードを再設定するURLやリダイレクトのホワイトリストを設定します。
```ruby
DeviseTokenAuth.setup do |config|
	# === #
	# 省略 #
	# === #
	
	# パスワード再設定を行うURLの設定
  config.default_password_reset_url = パスワード再設定させたいURL
  # 例：config.default_password_reset_url = "#{ENV['FRONT_URL']}/reset-password"

  # リダイレクトのホワイトリスト
  config.redirect_whitelist = [リダイレクトを許可したいURL郡]
  # 例
  # config.redirect_whitelist = [
  #   "#{ENV['FRONT_URL']}",
  #   "#{ENV['FRONT_URL']}/reset-password",
  #   "#{ENV['FRONT_URL']}/ja/reset-password",
  #   "#{ENV['FRONT_URL']}/en/reset-password"
  # ]
end
```

## 4. config/routes.rb
実装状況により異なりますが、今回はDeviseTokenAuthがUserモデルに紐づいている且つ`api/v1/auth/passwords_controller.rb`を利用すると想定します。
```ruby
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: 'auth',controllers: {
        passwords: 'api/v1/auth/passwords'
      }
    end
  end
end

```

## 5. back/app/controllers/api/v1/auth/passwords_controller.rb
`DeviseTokenAuth::PasswordsController`を継承したクラスを作ります。
もともとのだけでは正常に動作しなかったのでカスタマイズしました。
カスタマイズしない場合のコードはこの様になっております。

https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/controllers/devise_token_auth/passwords_controller.rb

今回カスタマイズした実装内容は

- SNS認証との多重ログイン
- SNSのメールアドレスと同じであれば同一ユーザーとみなす
- SNSログインをしておりメールアドレス・パスワードログインをしていないユーザーによるパスワードリセット

上記の要件で実装しております。
必要に応じて書き換えていただければと思います。

```ruby
class Api::V1::Auth::PasswordsController < DeviseTokenAuth::PasswordsController
  # パスワードリセットの作成
  def create
    # emailパラメータがなければ失敗で返却
    return render_create_error_missing_email unless resource_params[:email]

    # 大文字小文字を区別せずパラメーターからemailを取得
    @email = get_case_insensitive_field_from_resource_params(:email)
    # emailからUserを取得
    @resource = User.find_by(email: @email) if @email

    # ユーザーがいるかどうか
    # あるいはユーザーがブロックで渡されているかどうか
    if @resource
      yield @resource if block_given?
      
      # ユーザーがブロックで渡されていたらリセットパスワードのトークンを作成し
      # SESへメール送信
      @resource.send_reset_password_instructions(
        email: @email,
        provider: 'email',
        redirect_url: @redirect_url,
        client_config: params[:config_name]
      )

      # メール送信でエラーが発生していないか
      if @resource.errors.empty?
        # パスワードリセット申請メールの送信完了を返却
        return render_create_success
      else
	    # メール送信のエラーを返却
        render_create_error @resource.errors
      end
    else
	# ユーザーが見つからないエラーを返却
      render_not_found_error
    end
  end

  # パスワード更新
  # ユーザーから送られてきた新しいパスワードを更新する
  def update
	# パスワートリセット用のトークンが必要かどうか
	# また、パスワードリセット用のトークンがあるか
    if require_client_password_reset_token? && resource_params[:reset_password_token]
      # リセットパスワードのトークンをもとにユーザーを検索
      @resource = resource_class.with_reset_password_token(resource_params[:reset_password_token])
      # ユーザーがいなければ認証失敗
      return render_update_error_unauthorized unless @resource

      # ユーザーがいればトークンを作成
      @token = @resource.create_token
    else
	  # パスワードリセット用のトークンが不要またはパスワードリセット用のトークンがないときは
	  # トークンを生成
      @resource = set_user_by_token
    end

    # ユーザーがいなければ認証失敗
    return render_update_error_unauthorized unless @resource
	
    # メール認証していない場合は認証テーブルを確認
    unless @resource.provider == 'email'
	  # ユーザーの認証テーブルからemail認証があるかどうか
      email_auth = @resource.authentications.find_by(provider: 'email')
	  # emailの認証データがない = SNS認証しか行っていない場合
      unless email_auth.present?
        # emailのプロバイダーで認証データを作成
        @resource.authentications.create!(provider: 'email', uid: @resource.email)
      end
	  # ユーザーのプロバイダーにemailを設定
	  # SNSのプロバイダーだとパスワードリセットができないため
      @resource.provider = 'email'
    end

    # パスワードとパスワード確認のパラメータがあるか
    unless password_resource_params[:password] && password_resource_params[:password_confirmation]
      # なければ更新失敗を返却
      return render_update_error_missing_password
    end

    # パスワードを更新
    if @resource.send(resource_update_method, password_resource_params)
	  # パスワード再設定ができるのであれば、既存パスワードの確認をはずす
      @resource.allow_password_change = false if recoverable_enabled?
      # 新しいパスワードで保存
      @resource.save!
			
      # ユーザーがブロックで渡されていたら更新成功を返却
      yield @resource if block_given?
      return render_update_success
    else
	  # パスワード更新ができなければエラーを返却
      return render_update_error
    end
  end

  private

  # 更新成功のオーバーライド
  def render_update_success
    # 自動ログイン
    sign_in(@resource)

    # トークンを生成
    client_id   = SecureRandom.urlsafe_base64(nil, false)
    token       = SecureRandom.urlsafe_base64(nil, false)
    token_hash  = BCrypt::Password.create(token)
    expiry      = (Time.now + DeviseTokenAuth.token_lifespan).to_i

    @resource.tokens[client_id] = {
      token:  token_hash,
      expiry: expiry
    }

    # 新しいトークンを保存
    @resource.save!

    # ヘッダーにトークン情報を含める
    response.headers['Uid'] = @resource.uid
    response.headers['Client'] = client_id
    response.headers['Expiry'] = expiry
    response.headers['Access-Token'] = token

    # パスワード更新成功のフラグとメッセージを返却
    render json: {
      success: true,
      message: I18n.t('devise_token_auth.passwords.successfully_updated')
    }
  end

  # リダイレクト時のオプション設定
  # フロントとバックを完全に分離している場合、ドメインが異なるので
  # 別のドメインにリダイレクトさせるために必要
  def redirect_options
    {
      allow_other_host: true
    }
  end
end

```

## 4. フロントエンド
### 1. パスワード再申請のメールアドレスを送信
実装内容は割愛します。
今回のルーティングで言うと`バックのURL/auth/password`にメールアドレスをPOSTします。

例：https:\//example.com/auth/password

送信後に結果が返却されるので、送信の成功・失敗をユーザーに知らせます

## 2. パスワード再設定画面からパスワードを再送信
今回の実装ではURLにトークンが送られてきます。
このトークンを一緒に「パスワード」と「パスワード確認」をRailsへPUTします。
以下はサンプルです（Next.js、TypeScript使用）
```tsx
  useEffect(() => {
    const fetchData = async () => {
      const params = new URLSearchParams(window.location.search);
      if (params.get("reset_password") !== "true") {
        router.push(RouterPath.home);
        return;
      }

      const accessToken = params.get("token");
      const uid = params.get("uid");
      const client = params.get("client");
      const expiry = params.get("expiry");
      const isToken = accessToken && uid && client && expiry;
      if (isToken) {
		    // ここでトークン情報を保存する
        setAccessTokens(accessToken, client, uid, expiry);
      }
    };
    fetchData();
  }, []);
  
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    try {
      // Rails側へPutする
	  // Put2APIは独自に実装したものですが割愛します。
	  // Rails側へPut送信できればOKです。
      const res = await Put2API("/auth/password", {
        password,
        password_confirmation,
      });
      if (res.status !== 200) throw new Error();
      if (res.data.success) {
	    // レスポンスのヘッダー情報からトークンを再設定
        const accessToken = res.headers["access-token"];
        const client = res.headers.client;
        const uid = res.headers.uid;
        const expiry = res.headers.expiry;
        if (accessToken && client && uid && expiry) {
          setAccessTokens(accessToken, client, uid, expiry);
        }
      }
      // モーダルで結果を表示
      setModalMessage(res.data.message);
    } catch {
	    // モーダルでパスワード再設定失敗表示
      setModalMessage("パスワード再設定に失敗しました");
      return;
    } finally {
	    // モーダルを表示
      setModalOpen(true);
    }
  };
```

## 5. デプロイサーバーに環境変数設定
今回設定する環境変数は以下のとおりです
<table>
  <caption>設定する環境変数</caption>
  <tr>
    <td>FRONT_URL</td>
    <td>フロントのURLを設定します<br>
例：https://example.com</td>
  </tr>
  <tr>
    <td>HOST</td>
    <td>フロントのホストを設定します<br>
例：https://example.com</td>
  </tr>
  <tr>
    <td>AWS_ACCESS_KEY_ID</td>
    <td>IAMから発行されたアクセスキーを設定します</td>
  </tr>
  <tr>
    <td>AWS_SECRET_ACCESS_KEY</td>
    <td>IAMから発行されたシークレットアクセスキーを設定します</td>
  </tr>
  <tr>
    <td>AWS_REGION</td>
    <td>メール送信をするSESのリージョンを設定します。<br>
    コード内で使っていませんがこれがないリージョン設定がないとエラーが発生するので設定します。<br>
例（東京の場合）：ap-northeast-1</td>
  </tr>
</table>

# 終わり
以上で設定終わりです。
メールサーバーの選定として、AWSを使ってみたかったので使ってみました。
参考になれば幸いです！

# 参考

[Rubyドキュメント devise_token_auth](https://www.rubydoc.info/gems/devise_token_auth/0.1.43)
[Rubyドキュメント AWS:Rails](https://docs.aws.amazon.com/sdk-for-ruby/v1/api/AWS/Rails.html)
[Blogメモφ(..) Devise Token Authの挙動を確認してみた](https://blog.nightonly.com/2021/08/14/devise-token-authの挙動を確認してみた/)
[Qiita Ruby on RailsアプリでAWS SESを使ってメールを送信する方法（設定から実装まで）](https://qiita.com/YutoYasunaga/items/212be2ce4b53575bafa7)
[Qiita 【Rails】AWSのSESを使って本番環境でメールを送信する(aws-sdk-rails)](https://qiita.com/4ma9147/items/2b5fc6fa775b6efe8aaa)
[ちょいっぽ 【Rails】Amazon SESとは？本番環境でメール送信をする方法！](https://choippo.com/rails-production-amazon-ses/)
[Zenn Rails 6+Devise+AWS SESで簡単なログイン認証を実装する手順](https://zenn.dev/yukito0616/articles/3f224d68382c0d)
[Railsガイド Action Mailer の基礎](https://railsguides.jp/action_mailer_basics.html)
[Cloudflare AWS SES DKIM cannot be proxied?](https://community.cloudflare.com/t/aws-ses-dkim-cannot-be-proxied/237431)
[Zenn AWS SES + Ruby / Rails – メールが送信できない場合 ( aws-sdk-rails / aws-sdk-ses](https://zenn.dev/yumainaura/articles/qiita-2023-04-07t21_38_41-09_00_)
[GitHub:devise_token_auth / Unsafe redirect on signup [Rails 7.0] #1536](https://github.com/lynndylanhurley/devise_token_auth/issues/1536)