Fusic Tech Blog

Fusion of Society, IT and Culture

[Rails] Deviseでメールアドレスを使わず別プロパティで認証し、パスワードリセットにも対応する
2021/06/07

[Rails] Deviseでメールアドレスを使わず別プロパティで認証し、パスワードリセットにも対応する

弊社のRailsアプリでログイン機構を実装する場合、Deviseをよく使います。

今回は以下のような仕様で実装しました。

  • 管理者サイト、生徒サイト、先生サイトが存在する3層構造
  • 学校コードによってサイトを切り分け、それぞれの環境へログインする
  • 生徒・先生がログインする場合、メールアドレスを使用せず、学校コード生徒コード(教師コード)パスワードによって認証する
  • パスワードを忘れた場合、学校コード生徒コード(教師コード)メールアドレスによって認証し、パスワードリセットメールを送信する

手順

Deviseデフォルトのままでは実現できないので、以下の段階をふみました。

  1. Devise基本機能のまま、とりあえず3者がそれぞれのサイトにログインできるようにする
  2. 生徒、教師は認証時にメールアドレスを使わないよう変更する
  3. パスワードリセット時はメールアドレスも使うようにする

1. Devise基本機能のまま、とりあえず3者がそれぞれのサイトにログインできるようにする

Deviseの導入はそこかしこに先人達が情報を出してくれているのでもう良いでしょう。いつもお世話になっております。

https://qiita.com/cigalecigales/items/16ce0a9a7e79b9c3974e

メールを確認するのにLetter Openerを使っています。

以下のmodelを用意しました。

  • Administrator
  • School
  • Student
  • Teacher
  • Lesson

routesはこんな感じで学校間を区切れるようにschool_codeでスコープを切っています。(教師はさらに/tがつきます)

config/routes.rb

Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html

  # LetterOpener
  mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development?

  # システム管理者用画面
  namespace :admin, path: 'aaaaaaaa' do
    root to: 'schools#index', as: 'root'

    devise_for :administrators,
               only: %i[session password],
               controllers: { passwords: 'admin/passwords',
                              sessions: 'admin/sessions' }
    resources :administrators
    resources :schools do
      resources :teachers, param: :code, only: %i[show new create edit update]
    end
  end

  #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  # マルチテナントの切り分け
  scope '/:school_code' do
    #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    # 生徒用画面
    scope module: :student do
      root to: 'lessons#index', as: 'student_root'

      devise_for :students,
                 only: %i[session password registration confirmation],
                 controllers: { passwords: 'student/devise_passwords',
                                sessions: 'student/devise_sessions',
                                registrations: 'student/devise_registrations',
                                confirmations: 'student/devise_confirmations' }

      devise_scope :student do
        get   'students/registered',   to: 'devise_registrations#registered'
        patch 'students/confirmation', to: 'devise_confirmations#confirm'
      end

      resources :lessons, param: :code
    end

    #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    # 教師用画面
    namespace :teacher, path: 't' do
      root to: 'teachers#index', as: 'root'

      devise_for :teachers,
                 only: %i[session password],
                 controllers: { passwords: 'teacher/devise_passwords',
                                sessions: 'teacher/devise_sessions' }
    end
  end
end

ディレクトリはこのような感じです

ログイン前でも学校コードでチェックが必要なのでアクセサを用意し、viewで持ち回るように。

app/model/student.rb

  # attributes
  # アクセス中のスクールコード=URLに含まれるschool_codeをセット
  attr_accessor :current_school_code

app/views/student/devise_sessions/new.html.erb

  <%= f.hidden_field(:current_school_code, value: params[:school_code]) %>

ちなみにrails g devise:controllers teacher的なコマンドで、Teacherの下にDeviseの各Controllerが生成されますが、ファイル名の先頭にdevise_は付きません。 今回は手作業でファイル名を書き換えています。

ここまでで通常のメールアドレス+パスワードのログインを試し、できたら次にすすむ!

2. 生徒、教師は認証時にメールアドレスを使わないよう変更する

通常のログイン機構はできたのでここで認証に手を加えます。

authentication_keysはデフォルトemailなので、学校コードと生徒コードを指定します。(ここはcurrent_school_codeになります)

app/model/student.rb

class Student < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :trackable, :recoverable, :confirmable, :registerable,
         :rememberable, :validatable,
         authentication_keys: %i[current_school_code code],
         strip_whitespace_keys: %i[code email]

  # [Devise] 全ての認証時にユーザーを特定する
  # ログイン・パスワードリセット・それ以外で認証条件を分ける
  def self.find_first_by_auth_conditions(warden_condition)
    if login?(warden_condition)
      school = School.find_by(code: warden_condition[:current_school_code])
      school.students.find_by(code: warden_condition[:code])
    else
      # token利用時
      super(warden_condition)
    end
  end

Deviseのすべての認証が走る時に来る find_first_by_auth_conditions をオーバーライドします。

ログイン時の処理だけ独自のコードを使用し、パスワードリセット時などのtoken認証する場合はsuperでDeviseにおまかせしています。

authentication_keys がきちんと変わっているのかログイン画面に適当な値を入力しpryで止めてみてみます。

warden_conditionがきちんと変わっている!

[1] pry(Student)> warden_condition
=> {:current_school_code=>"kita", :code=>"aaaa"}

あとはviewを生徒コードに書き換えて(hiddenのcurrent_school_codeも忘れずに)完了!

3. パスワードリセット時はメールアドレスも使うようにする

ここまでの対応でログインは生徒コード/パスワード(URLの学校コード)でできるようになりました。

次はパスワードを忘れた場合、パスワードリセットメールを送って新しいパスワードをセットできるようにします。

ログインの場合と違い、生徒コード/メールアドレス(URLの学校コード)でチェックし、OKであればメールを送ります。

reset_password_keysをmethodで設定し、ログイン時に対応しfind_first_by_auth_conditionsまわりに手を加えます。 リセットパラメーターにログインパラメーターを内包しているので、リセットのチェックを先に行い、その他はsuperで任せるようにしました。

app/models/student.rb

  # [Devise] パスワードリセット時の認証を設定
  # Email, 学校コード、生徒コードの3点で行う
  def self.reset_password_keys
    %i[email current_school_code code]
  end

  # [Devise] 全ての認証時にユーザーを特定する
  # ログイン・パスワードリセット・それ以外で認証条件を分ける
  def self.find_first_by_auth_conditions(warden_condition)
    if password_reset?(warden_condition) # リセットにログインパラメーターを内包しているので必ず先に
      school = School.find_by(code: warden_condition[:current_school_code])
      school.students.find_by(email: warden_condition[:email],
                              code: warden_condition[:code])
    elsif login?(warden_condition)
      school = School.find_by(code: warden_condition[:current_school_code])
      school.students.find_by(code: warden_condition[:code])
    else
      # token利用時
      super(warden_condition)
    end
  end

  # [Devise] ログイン時は学校コード、生徒コードで認証
  def self.login?(warden_condition)
    warden_condition.key?(:current_school_code) && warden_condition.key?(:code)
  end

  # [Devise] パスワードリセット時はメール、学校コード、生徒コードで認証
  def self.password_reset?(warden_condition)
    warden_condition.key?(:email) &&
      warden_condition.key?(:current_school_code) &&
      warden_condition.key?(:code)
  end

さらにメールアドレスの存在確認に利用されるのを防ぐために詳しいエラーメッセージは出したくないということで、空白(:blank)以外のエラーは無に帰し、成功でもエラーでも「内容が合っていればメールが届くよ」というメッセージを出すようにしました。

認証後に来るsuccessfully_sent?をオーバーライドし、地味にエラーメッセージをフィルタリングしています。

あとから気づいたけど、Deviseのparanoidオプションてのがあるな?と。これについては調べてないのでまた今度。。

app/controllers/student/devise_passwords_controller.rb

  protected

  # send_***_instructionsの後に呼ばれる(認証とメール送信の間)
  def successfully_sent?(resource)
    notice = if Devise.paranoid
               resource.errors.clear
               :send_paranoid_instructions
             elsif resource.errors.empty?
               :send_instructions
             else
               # セキュリティ上、詳細メッセージはフィルタリングする
               filter_error_messages
             end

    return unless notice

    set_flash_message! :notice, notice
    true
  end

  private

  # blank以外のエラーメッセージは削除する
  def filter_error_messages
    ignores = []
    resource.errors.each do |e|
      ignores << e.attribute if e.type != :blank
    end
    ignores.each do |attribute|
      resource.errors.delete(attribute)
    end

    :send_instructions if resource.errors.empty?
  end

メールのフォーマットも、生徒・先生で分けたい/URLに学校コードが必要なのでオーバーライドし、各種設定ファイルも修正します。

app/mailers/devise_mailer.rb

class DeviseMailer < Devise::Mailer

# 中略

  def reset_password_instructions(record, token, opts = {})
    super(
      record,
      token,
      opts.merge(
        template_path: "#{record.class.name.downcase}/devise_mailer",
        subject: subject('reset_password_instructions')
      )
    )
  end

  private

  def subject(type)
    app_name = I18n.t('system.app_name')
    subject = I18n.t("devise.mailer.#{type}.subject")
    env_info = Rails.env.production? ? nil : "[#{Rails.env}]"

    "#{env_info}[#{app_name}]#{subject}"
  end
end

config/initializers/devise.rb

  # Configure the class responsible to send e-mails.
  config.mailer = 'DeviseMailer' # ::

メール内のURL表現はこうなります app/views/student/devise_mailer/reset_password_instructions.html.erb

  <% url = edit_student_password_url(school_code: @resource.school.code, reset_password_token: @token) %>

ここまででパスワードリセットをいろいろ試し動きを確認します。

エラーになるいろんな条件と1回だけ成功バージョンを試すときちんと1回分のみリセットメールが来ました\(^o^)/

参考文献 *ありがとうございます

https://zenn.dev/kitabatake/articles/start-to-like-the-devise

泣いた件

この記事を書くにあたり、お仕事のをそのまま出せないので新規にサンプルアプリを作ったところ、errorsの構造が違ってて泣きました。

pryの下の方見てください。Arrayて!下はActiveModel::Errorなのに。。 自分だけでは原因のあたりがつけられず無駄に時間を溶かしました。

自分で作ったやーつ。Rails 6.0.3

    67: def filter_error_messages
    68:   ignores = []
    69:   resource.errors.each do |e|
    70:     binding.pry
 => 71:     ignores << e.attribute if e.type != :blank
    72:   end
    73:   ignores.each do |attribute|
    74:     resource.errors.delete(attribute)

[1] pry(#<Student::DevisePasswordsController>)> resource.errors
=> #<ActiveModel::Errors:0x00007fc8db346e70
 @base=
  #<Student id: nil, school_id: nil, family_name: nil, given_name: nil, code: "std-0001", email: "minami_0000@example.com", created_at: nil, updated_at: nil>,
 @details={:email=>[{:error=>:not_found}], :current_school_code=>[{:error=>:not_found}], :code=>[{:error=>:not_found}]},
 @messages={:email=>["は見つかりませんでした。"], :current_school_code=>["は見つかりませんでした。"], :code=>["は見つかりませんでした。"]}>
[2] pry(#<Student::DevisePasswordsController>)> resource.errors.class
=> ActiveModel::Errors
[3] pry(#<Student::DevisePasswordsController>)> resource.errors.first.class
=> Array
[6] pry(#<Student::DevisePasswordsController>)>```

お仕事のやーつ。Rails 6.1.3

    66: def filter_error_messages
    67:   ignores = []
    68:   resource.errors.each do |e|
    69:     binding.pry
 => 70:     ignores << e.attribute if e.type != :blank
    71:   end
    72:   ignores.each do |attribute|
    73:     resource.errors.delete(attribute)

[1] pry(#<Student::DevisePasswordsController>)> resource.errors
=> #<ActiveModel::Errors:0x00007feb6fefa648
 @base=
  #<Student id: nil, school_id: nil, family_name: nil, given_name: nil, code: nil, status: nil, email: "aaa@aa.jp", created_at: nil, updated_at: nil, core_system_number: "aba", family_name_kana: nil, given_name_kana: nil, stage: "initial", license_type_id: 1, shelf: nil, entrance_date: nil, is_twin: nil>,
 @errors=
  [#<ActiveModel::Error attribute=email, type=not_found, options={}>,
   #<ActiveModel::Error attribute=current_school_code, type=not_found, options={}>,
   #<ActiveModel::Error attribute=core_system_number, type=not_found, options={}>]>
[2] pry(#<Student::DevisePasswordsController>)> resource.errors.class
=> ActiveModel::Errors
[3] pry(#<Student::DevisePasswordsController>)> resource.errors.first.class
=> ActiveModel::Error

こういうことだったみたい。

https://code.lulalala.com/2020/0531-1013.html

とりあえずアップグレードガイドみつつバージョンを上ました。

https://railsguides.jp/upgrading_ruby_on_rails.html

@yuuuありがとう!いつもお世話になってます!

yukabeoka

yukabeoka

カスタマーサポートからエンジニアにジョブチェンジ。脳の老化に抗いがんばる。最近はAzureにいじめられている。