はじめに
はじめまして!運営メンバーのなかやん(@nakayarn)といいます!今回、TechBullコミュニティ内で、会員管理システムを作るにあたって、SendGridを使用したパスワードリセット機能を実装させて頂きました。その内容をブログに記載させていただきます。
会員管理システムについては@adachin0817さんのスライドを参考に!
自己紹介
@adachin0817さんとはエンジニアイベントを通じて知り合い、コミュニティに参加しました。現在は、未経験エンジニア向けの研修や、プログラミングスクールでの質問対応・就職支援を担当しております。メインの開発言語はRuby on Railsで、時々Javaも使用。サービス全体の構造を理解したいという思いから、フロントエンドやクラウドなど幅広い分野を横断的に学習中です!よろしくお願いします!
ローカル開発環境
- バックエンド:Ruby on Rails(API)
- フロントエンド:React
- 開発環境:Docker
- 前提条件
- deviseでのログイン機能を実装済みの状態からパスワードリセット機能を実装
- .envファイルで環境変数を扱える環境が整っている
バックエンドの内容のみを記載いたします。セキュリティに関しては、以下の点に注意して実装しました。
- パスワードリセット用トークンは10分で有効期限が切れるようにする
- 一度使用したトークンは再利用できないようにする
SendGrid APIとは?
メール配信に特化したクラウドプラットフォームです。高い配信率を誇るそうで、今回はパスワードリセット用のメール配信に使用します。無料トライアル期間は60日間となっており、その後は有料プランに移行する必要があります。
実装
- SendGrid APIキーの取得
SendGridのWebサイトに登録を行い、APIキーを生成します。
「API Keys」>「Create API Key」からAPIキーを生成することができます。
権限は「Full Access」または「Mail Send」権限を付与します。セキュリティの観点から「Mail Send」権限を推奨します。
設定したメールアドレスに認証メールが届くので、認証が完了すればAPIを使用することができます。
- 環境変数の設定
APIで使用するAPIキーとメールアドレスは.envファイルで管理するため、環境変数を設定します。
- .env
1 2 |
SENDGRID_API_KEY="your_sendgrid_api_key" SENDGRID_FROM_EMAIL="your_email@yourapp.com" |
-
- gemの導入
Ruby用の専用gemが提供されているので、sendgrid-rubyを使用します。また、メール送信確認で使用するletter_opener_webというgemも一緒に導入します。
Gemfileに以下を追記して、bundle installを実行します。
-
- Gemfile
1 2 3 4 5 6 |
〜省略〜 gem 'sendgrid-ruby' # letter_opener_webは開発環境・テスト環境にのみ設定 group :development, :test do gem "letter_opener_web" end |
- ハッシュ化したトークンと発行日時を保存するカラムの作成
トークンの発行や管理を行うため、必要なカラムを追加します。ターミナルで以下のコマンドを実行してマイグレーションファイルを作成します。(既にusersテーブルが存在するので、カラムの追加のみ)
1 |
$ docker compose exec app rails g migration AddPasswordResetFunctionalityToUsers |
作成されたマイグレーションファイルに以下のコードを記載します。
1 2 3 4 5 6 7 8 9 10 |
class AddPasswordResetFunctionalityToUsers < ActiveRecord::Migration[8.0] def change # パスワードリセット用のカラムを追加 add_column :users, :reset_password_token, :string add_column :users, :reset_password_sent_at, :datetime # reset_password_tokenにユニークインデックスを追加 add_index :users, :reset_password_token, unique: true end end |
マイグレーションを実行します。
1 |
$ docker compose exec app rails db:migrate |
schema.rbファイルを確認し、reset_password_token
とreset_password_sent_at
が追加されていることを確認します。
- パスワードリセット用のコントローラーの作成
このコントローラーで実装したい機能は以下の通りです。
1. リセットメール送信 (create)
メールアドレスを受け取ってリセットメールを送信
2. トークン有効性確認 (show)
リセットリンクの有効性のチェック
3. パスワード更新 (update)
新しいパスワードで更新実行&トークンのクリア
- app/app/controllers/api/password_resets_controller.rb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
class Api::PasswordResetsController < ActionController::API # API通信のためレスポンスをJSONに設定する before_action :set_default_response_format # パスワードリセットメール送信 def create # リクエストからメールアドレスを取得 email = password_reset_params[:email] # メールアドレスが空の場合はエラーを返す if email.blank? render json: { message: "メールアドレスが必要です" }, status: 400 return end # メールアドレスでユーザーを検索 user = User.find_by(email: password_reset_params[:email]) # ユーザーが存在する場合のみトークン生成とメール送信 if user # リセット用トークンを生成 token = generate_reset_token # ユーザー情報を更新 user.update!( reset_password_token: Digest::SHA256.hexdigest(token), reset_password_sent_at: Time.current ) # パスワードリセットメールを送信 UserMailer.password_reset(user, token).deliver_now end # セキュリティのため、ユーザーの存在に関わらず同じメッセージを返す render json: { message: "パスワードリセット処理が完了しました。メールをご確認ください。" } rescue StandardError => e Rails.logger.error("Password reset error: #{e.message}") render json: { message: "メール送信に失敗しました" }, status: 500 end # トークン有効性を確認 def show token = params[:token] # トークンが空の場合はエラーを返す if token.blank? render json: { message: "トークンが指定されていません", valid: false }, status: 400 return end # トークンでユーザーを検索 user = find_user_by_token(token) # ユーザーが存在し、トークンが有効な場合 if user&.token_valid? render json: { message: "トークンは有効です", email: user.email, valid: true } else # トークンが無効または期限切れの場合 render json: { message: "トークンが無効または期限切れです", valid: false }, status: 422 end end # パスワードを更新 def update # パスワードが空の場合は早期リターン return render json: {}, status: :bad_request if user_params[:password].blank? || user_params[:password_confirmation].blank? token = params[:token] # トークンが空の場合はエラーを返す if token.blank? render json: { message: "トークンが指定されていません" }, status: 400 return end user = find_user_by_token(token) # ユーザーが存在し、トークンが有効な場合 if user&.token_valid? # パスワード更新とリセットトークンのクリア if user.update(user_params.merge(clear_reset_token)) render json: { message: "パスワードが正常に更新されました" } else # バリデーションエラーの場合 render json: { message: "パスワード更新に失敗しました", errors: user.errors.full_messages }, status: 422 end else # トークンが無効または期限切れの場合 render json: { message: "トークンが無効または期限切れです" }, status: 422 end end private # レスポンス形式をJSONに設定 def set_default_response_format request.format = :json end # ランダムトークンを生成 def generate_reset_token SecureRandom.urlsafe_base64(32) end # ハッシュ化されたトークンでユーザーを検索 def find_user_by_token(token) User.find_by(reset_password_token: Digest::SHA256.hexdigest(token)) end # パスワードリセットのパラメータを許可 def password_reset_params params.permit(:email) end # パスワード更新時のパラメータを許可 def user_params params.require(:user).permit(:password, :password_confirmation) end # リセットトークンをクリアするためのハッシュ def clear_reset_token { reset_password_token: nil, reset_password_sent_at: nil } end end |
セキュリティの観点から、メールアドレスを送信した際に、ユーザーの有無に関わらず同じレスポンスを返すようにしました。
- モデルにメソッドの追加
トークンの有効期限をチェックするメソッドはuserモデルに記載します。
- app/models/user.rb
1 2 3 4 5 |
# パスワードリセットトークンの有効期限をチェック def token_valid? return false unless reset_password_sent_at Time.current - reset_password_sent_at <= 10.minutes end |
- ルーティングの設定
ルーティングはcreate・show・updateのみ作成します。
1 2 3 4 5 6 7 8 |
Rails.application.routes.draw do namespace :api do 〜省略〜 get "password_resets", to: "password_resets#show" post "password_resets", to: "password_resets#create" patch "password_resets", to: "password_resets#update" end end |
- ActionMailerの設定
メールを送信するための設定を行います。APIのメールアドレスが変更される可能性があるため、環境変数を使用します。
- app/mailers/application_mailer.rb
親クラス:送信者メールアドレスのデフォルト設定
1 2 3 4 5 6 7 8 9 |
class ApplicationMailer < ActionMailer::Base DEFAULT_FROM_EMAIL = "default@email.com" FROM_EMAIL = ENV.fetch("SENDGRID_FROM_EMAIL", DEFAULT_FROM_EMAIL) Rails.logger.warn("SENDGRID_FROM_EMAIL環境変数がありません。デフォルトを使用します: #{DEFAULT_FROM_EMAIL}") if FROM_EMAIL == DEFAULT_FROM_EMAIL default from: FROM_EMAIL layout "mailer" end |
- app/mailers/user_mailer.rb
子クラス:パスワードリセットメールの設定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class UserMailer < ApplicationMailer default from: ENV["SENDGRID_FROM_EMAIL"] || "noreply@techbull.com" def password_reset(user, token) @user = user @reset_url = "#{frontend_url}/reset-password?token=#{token}" mail( to: @user.email, subject: "パスワードリセットのご案内 - TechBull" ) end private def frontend_url if Rails.env.production? ENV["FRONTEND_URL_PRODUCTION"] else ENV["FRONTEND_URL_DEVELOPMENT"] end end end |
- 環境変数の設定(追記)
フロントエンドのURLを環境によって使い分ける設定を行ったため、.envファイルに以下の環境変数を追加します。
1 2 |
FRONTEND_URL_PRODUCTION="https://production.cloud" FRONTEND_URL_DEVELOPMENT="https://development.cloud:4000" |
- パスワード再設定用メールテンプレートの作成
送信するメールのテンプレートを作成します。ここで、app/mailers/user_mailer.rbで設定した@userと@reset_urlの変数を使用します。
- app/views/user_mailer/password_reset.html.erb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; } .container { max-width: 600px; margin: 0 auto; padding: 20px; } .button { background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; margin: 20px 0; } .footer { margin-top: 30px; font-size: 12px; color: #666; } </style> </head> <body> <div class="container"> <h2>パスワードリセットのご案内</h2> <p><%= @user.name %>様</p> <p>TechBullアカウントのパスワードリセットが申請されました。</p> <p>以下のボタンをクリックして、新しいパスワードを設定してください:</p> <a href="<%= @reset_url %>" class="button">パスワードを再設定する</a> <p>上記ボタンが機能しない場合は、以下のURLをコピーしてブラウザに貼り付けてください:</p> <p><%= @reset_url %></p> <p><strong>注意事項:</strong></p> <ul> <li>有効期限は<%= (@user.reset_password_sent_at + 10.minutes).strftime('%Y/%m/%d %H:%M') %>です。</li> <li>パスワードリセットを申請していない場合は、このメールを無視してください</li> <li>アカウントの安全のため、リンクを他の人と共有しないでください</li> </ul> <div class="footer"> <p>このメールに心当たりがない場合は、お手数ですがサポートまでご連絡ください。</p> <p>© <%= Time.current.year %> TechBull. All rights reserved.</p> </div> </div> </body> </html> |
- 開発環境用のメール送信設定
開発環境でメールを送信できるようにするために設定を行います。
- config/environments/development.rb
letter_opener_webというgemを使用してメール送信テストを実施します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Rails.application.configure do 〜省略〜 # letter_opener_webの設定 config.action_mailer.delivery_method = :letter_opener_web # メール配信の有効化 config.action_mailer.perform_deliveries = true # 配信エラーの設定 config.action_mailer.raise_delivery_errors = true # URLオプションの設定 config.action_mailer.default_url_options = { host: "dev-api.example.com", protocol: "https" } end |
curlを使用したAPIエンドポイントテスト
まだフロントエンド部分を使用していないため、curlコマンドを使用して動作確認を行います。
- 別のターミナルでRailsコンソールを起動
1 |
$ docker compose exec app rails console |
- メール送信
1 2 3 |
curl -X POST http://localhost:3000/api/password_resets \ -H "Content-Type: application/json" \ -d '{"email": "YOUR_EMAIL@example.com"}' |
コマンドを実行後、設定したメッセージ {“message”: “パスワードリセット処理が完了しました。メールをご確認ください。”} が返ってくればOK!
letter_opener_webをブラウザで開いてメールに記載のトークンを確認します。
- トークンの有効性の確認
メールで確認したトークンを使用して有効性を確認します
1 2 |
curl -X GET "http://localhost:3000/api/password_resets?token=YOUR_TOKEN" \ -H "Content-Type: application/json" |
- パスワード更新
newpassword123という値でパスワードを更新します。
1 2 3 4 5 6 7 8 9 |
curl -X PATCH http://localhost:3000/api/password_resets \ -H "Content-Type: application/json" \ -d '{ "token": "YOUR_TOKEN", "user": { "password": "newpassword123", "password_confirmation": "newpassword123" } }' |
パスワードが更新され、 {“message”:”パスワードが正常に更新されました”} というメッセージが表示されれば成功です!
おわりに
SendGrid APIを使用したパスワードリセット機能を実装したのが初めてだったので、とても勉強になりました。また、TechBullコミュニティの方々からコードレビューをいただき、大変勉強になりました!!
SendGrid APIは長めの無料期間があるので、ご興味のある方は是非実装してみてください。長い文章をお読みいただき、ありがとうございました!

@adachin0817さんとはエンジニアイベントを通じて知り合い、コミュニティに参加。現在は、未経験エンジニア向けの研修や、プログラミングスクールでの質問対応・就職支援を担当。メインの開発言語はRuby on Railsで、時々Javaも使用。サービス全体の構造を理解したいという思いから、フロントエンドやクラウドなど幅広い分野を横断的に学習中。