LoginSignup
0
0

Devise 使用 Rails アプリで、パスワード変更機能をユーザー情報編集から分離➡パスワードリセットと統合してみた話

Posted at

概要(やってみたこと)

  1. ユーザー編集画面で、パスワードとそれ以外の属性の編集画面を別々にする
  2. パスワード以外の編集時は確定時のパスワード再入力を不要とする
  3. パスワード変更画面/処理はパスワードリセットのView/Actionと共用にしてパスワード変更処理を一つにする

末筆の通りあまりお行儀は良くない気がするので、コードの量より分かりやすさが重要な状況(大規模プロジェクトetc.)には向かないと思います。
コード量抑制を重視する状況下では使いどころもあるかもしれませんが、こんなやり方もできたという一種の頭の体操的に捉えて頂けますと幸いです🙏

なお前提として、本稿内では Devise の認証対象のモデルは「User」としています。
モデル名が異なる場合は適宜読み替えてください。

ソース・環境

ソース全文・全履歴は下記になります。なるべく対応内容ごとの変化が分かりやすいようにコミット単位も調整しました。

ローカル環境は Windows 10 Pro、Ruby3.2.2、Rails7.1.2

各段階の詳細手順

前準備

既に Devise 導入が完了しているプロジェクトであれば不要な手順だと思いますが
断片的に必要な部分もあるかもしれません(カスタマイズ対象コントローラの追加etc.)ので各々ご判断ください

Gemfile に devise (と、必要なら devise-i18n)を追加した上で下記を実行します。
(この辺りは devise 導入の基本的な手順なので詳細は割愛します)

$ bundle install
$ rails g devise:install
$ rails g devise User

Userモデル app/models/user.rb とそのマイグレーションファイル db/migration/YYYYMMDDHHmmss_devise_create_users.rb について recoverable モジュールが有効化されていることを確認します1

User(認証対象)モデルにメール・パスワード以外の属性を持たせる場合はマイグレーションファイルに追記2(今回のサンプルコードでは必須属性として名前:name、任意属性として職業:job・趣味:hobby を追加)します。
追記が終わったら(なければそのまま)続いて下記を実行します。

$ rails db:migrate
$ rails g devise:views users
$ rails g devise:controllers users -c registrations passwords

カスタマイズが必要なコントローラは最後のコマンドの通り registrationspasswords なのでこの2つを生成し、ルーティングの設定を行います

config/routes.rb
- devise_for :users
+ devise_for :users, controllers: {
+  registrations: 'users/registrations',
+  passwords: 'users/passwords'
+ }

パスワードとそれ以外の属性の編集画面を別々にする(ユーザー編集画面/アクションをパスワード以外の属性の編集に特化)

Devise 元々の(=$ rails g devise:views usersで作られる)ユーザー情報編集ビュー users/registrations/edit.html.erb はメール(email)とパスワード(現在/変更/確認)の入力欄しかないので、
それ以外の属性の入力欄をフォームに追加する一方でパスワード関連の欄3つ:password, password_confirmation, current_password は削除します。

また対応するアクションは RegistrationsControllerupdate アクションになります。
これ自体は Devise 元々の実装のままでいいのでオーバーライド(カスタマイズ)は不要(def update 以下自体記述の必要なし、敢えて明記したい場合は下記)ですが、

app/controllers/users/registrations_controller.rb
# 以下まるまる書かなくても違いはない
def update
  super
end

追加の属性がストロングパラメータに入っていなくそのままだと送信できない(弾かれてしまう)ので、今回の例であれば name, job, hobby を下記の通り追加します3

app/controllers/users/registrations_controller.rb
# If you have extra params to permit, append them to the sanitizer.
def configure_sign_up_params  # 編集用ではなく新規登録用だが追加属性あるなら必要
  devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :job, :hobby])
end

# If you have extra params to permit, append them to the sanitizer.
def configure_account_update_params
  devise_parameter_sanitizer.permit(:account_update, keys: [:name, :job, :hobby])
end

パスワード変更機能は passwords_controller#update アクション及び users/passwords ビューへ移しますが、これについては要の部分なので後述します。

パスワード以外の編集時は確定時のパスワード再入力を不要とする

Devise の元々の仕様ではユーザー情報編集時にはパスワードの入力が必要なのでこのままでは動きません。
前項で RegistrationsController#update はオーバーライド不要――と書いたのは嘘ではないですが、その中から呼ばれている update_resource メソッドを下記の通りオーバーライドすることで意図する挙動に変更する必要があります。

app/controllers/users/registrations_controller.rb
def update_resource(resource, params)
  resource.update_without_password(params)
end

こちらについても参考記事は散見されますので詳細はそれら(下記は一例)をご参照のほど。

パスワード変更画面/処理はパスワードリセットのView/Actionと共用にしてパスワード変更処理を一つにする

ここから本記事の主題です。前項の参考記事にもある通りここまでの修正で登録情報編集時にパスワードが不要になったのはよいものの、パスワード自体の変更もできなくなってしまっているのでその後に条件分岐やらなんやら追加して云々――とやっていますが正直ちょっと複雑ですよね。💦
で、そもそもパスワード変更って忘却時のリセットでもやってたよな?使えないかな?ということでそちらに寄せられないか探ってみた(で一応できた)結果がこちらです。

ビュー

具体的には、まずビュー app/views/users/passwords/edit.html.erb はパスワードリセット要求時に発行される reset_password_token の欄がありますが、こちらはリセット即ち非ログイン状態でのパスワード変更時のみ必要な一方
ログイン中のパスワード変更は元々のユーザー情報変更と同様に現在(変更前)のパスワードを要求するようにするため、下記のようにログイン中か否かで表示する入力欄を変更します。

app/views/users/passwords/edit.html.erb
+ <% if user_signed_in? %>
+   <div class="field">
+     <%= f.label :current_password, "Current password" %><br />
+     <%= f.password_field :current_password, autofocus: true, autocomplete: "password" %>
+   </div>
+ <% else %>
    <%= f.hidden_field :reset_password_token %>
+ <% end %>

この対応で passwords/edit.html.erb 及びエラーメッセージパーシャルの resource がnilになる状況が生じます(その理由はまだ詳しく判明してないので突き止められたら追記します)が、
$ rails g devise:views users したままではその考慮がされておらずnil落ちするのでそれぞれnil対策を追加します。

edit.html.erb は伝統的(?)なフォームヘルパー form_for で下記のエラーになりますが、

デフォルト(form_forのまま)だと出るエラー
ActionView::Template::Error: First argument in form cannot contain nil or be empty

下記のようにモダン(??)な form_with に書き換えたらnil落ちを回避できました。

app/views/users/passwords/edit.html.erb
- <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
+ <%= form_with model: resource, url: password_path(resource_name), html: { method: :put } do |f| %>

Devise のエラーメッセージパーシャルも冒頭の resource 存否確認にnil考慮を追加します。
ここは普通にnilガードを入れてやれば大丈夫でした。

app/views/users/shared/_error_messages.html.erb
- <% if resource.errors.any? %>
+ <% if (resource&.errors || []).any? %>  <!-- if resource&.errors&.any? でもよい -->

コントローラ

最後にコントローラですが、PasswordsController を下記のように実装します。
(元のコメントアウトコードをまるまる下記に置き換える形でOK)

app/controllers/users/passwords_controller.rb
# frozen_string_literal: true

class Users::PasswordsController < Devise::PasswordsController
  # 下記ポイント1.
  skip_before_action :assert_reset_token_passed, if: :user_signed_in?
  skip_before_action :require_no_authentication, if: :user_signed_in?
  # ▲ only: が効かない(if: と併用不可? 詳細不明)ため ▼ を別途指定
  before_action :require_no_authentication, only: [:new]  # skip_before_action より前だと効かない

  # PUT /resource/password
  def update  # 下記ポイント2.
    return super unless user_signed_in?

    if current_user.update_with_password(update_pw_params)
      bypass_sign_in(current_user)  # PW変更時の強制ログアウト回避のため
      flash[:success] = "Password changed!"
      redirect_to root_path
    else
      flash[:danger] = "Failed to change password"
      render 'users/passwords/edit', status: :unprocessable_entity
    end
  end

  protected
    def update_pw_params  # 下記ポイント3.  メソッド名は任意
      params.require(:user).permit(:current_password, :password, :password_confirmation)
    end
end

ポイントは下記の3つ。

  1. Devise 元々のパスワードリセット画面は、ログイン中、及びリセットトークン未発行時は表示させない(リダイレクトする)設定が入っています。
    ログイン中のパスワード変更ではいずれも無効化したいので、それらのスキップ指定 skip_before_actionif: :user_signed_in? の条件付きで追加します。
    :require_no_authenticationnew アクションも対象なので only: [:edit, :update] などとして影響しないようにしたいところですが、それだと効かなかった――if: とは併用できない?――ため
    before_action :require_no_authentication, only: [:new]最後に再度指定することでやっと意図通りに動きました。なお記載順序が違うとやはり効かなかったのでご注意。
  2. update アクションは、未ログイン時 unless user_signed_in? は元々のパスワードリセット処理をそのまま実行でよいため return super
    ログイン時については update_with_password メソッド+コントローラ定番のif~elseでユーザー情報の更新処理を実装します。
    デフォルトではパスワードを更新すると強制的にログアウトさせられるため、回避するには bypass_sign_in を入れておきます4
  3. ストロングパラメータは現在のパスワード :current_password・変更後 :password・変更後確認 :password_confirmation の3つを受け入れるようにします。

以上で

  • ユーザー情報編集画面からパスワード変更機能を外し、パスワード確認入力も不要に
  • パスワードリセットもログイン中のパスワード変更も同じ画面/アクションで実現

できました。

冒頭でも触れた通りちゃんとお行儀よく作るなら、単一責任の原則から言っても ユーザー情報編集画面・パスワード変更・パスワードリセット いずれも個別にビュー・アクションを設けた方がよいかと思いますが、
独りであれやこれや弄っている場合など、少ないソース量で手っ取り早く実現したければこういうやり方もできるというご紹介でした。

  1. デフォルトで有効なのでこの手順通り Devise を新たに追加していれば問題ないが、元々追加済の場合は要確認

  2. 後からカラム追加のマイグレーションを別途作成してももちろんOK

  3. 通常のコントローラのストロングパラメータとは書き方が少々異なるので注意

  4. こちらconfig.sign_in_after_reset_password = true でも回避できる旨情報があったが未検証

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0