Table of Contents
手順
Deviseデフォルトのままでは実現できないので、以下の段階をふみました。
- Devise基本機能のまま、とりあえず3者がそれぞれのサイトにログインできるようにする
- 生徒、教師は認証時にメールアドレスを使わないよう変更する
- パスワードリセット時はメールアドレスも使うようにする
1. Devise基本機能のまま、とりあえず3者がそれぞれのサイトにログインできるようにする
Deviseの導入はそこかしこに先人達が情報を出してくれているのでもう良いでしょう。いつもお世話になっております。
メールを確認するのに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^)/
参考文献 *ありがとうございます
泣いた件
この記事を書くにあたり、お仕事のをそのまま出せないので新規にサンプルアプリを作ったところ、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
こういうことだったみたい。
とりあえずアップグレードガイドみつつバージョンを上ました。
@yuuuありがとう!いつもお世話になってます!
yukabeoka
カスタマーサポートからエンジニアにジョブチェンジ。脳の老化に抗いがんばる。最近はAzureにいじめられている。