こんにちは!kossyです!
今回は、アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。
環境
Ruby 2.6.8
Rails 6.0.4
devise 4.8.0
なお、コードの説明の前提として、既にdeviseが導入済みで、deviseを利用しているUserというモデルが定義されていることとします。
deviseのlockableとは?
アカウントロック機能を提供するdeviseのモジュールの一つです。これだけだと説明としてあまりにも淡白なので、ソースコードのコメントアウト部分を意訳してみます。
Handles blocking a user access after a certain number of attempts.
Lockable accepts two different strategies to unlock a user after it's blocked: email and time. The former will send an email to the user when the lock happens, containing a link to unlock its account.
The second will unlock the user automatically after some configured time (ie 2.hours).
It's also possible to set up lockable to use both email and time strategies.特定の回数ログインに失敗したユーザーのアクセスをブロックします。
Lockableは、ブロックされたユーザーのアカウントロックを解除するために、電子メールと時間という2つの異なる戦略を受け入れます。前者は、ロックが発生したときに、アカウントのロックを解除するためのリンクを含む電子メールをユーザーに送信します。
2つ目は、設定された時間(つまり、2時間)後にユーザーのロックを自動的に解除します。電子メールと時間の両方の戦略を使用するようにロック可能を設定することも可能です。
「特定の回数ログインに失敗したユーザーのアクセスをブロックする機能」
「ロック解除にはメールのリンクを時間内に踏む必要がある」
この2つを抑えておけばひとまずOKでしょう。
lockableを使うには
locableを有効にするには、deviseを利用するモデルでlockableをincludeすることと、いくつかのカラムをモデルに追加する必要があります。
class User < ApplicationRecord devise :database_authenticatable, :lockable end
class AddTrackableColumnsToUsers < ActiveRecord::Migration[6.0] def change change_table :users do |t| # Lockableに必要なカラム t.integer :failed_attempts, default: 0, null: false t.string :unlock_token t.datetime :locked_at end add_index :users, :unlock_token, unique: true end end
これで準備OKです!
configの確認
まずはOptions部分のコメントアウトを訳してみます。
Lockable adds the following options to 「devise」
「maximum_attempts」how many attempts should be accepted before blocking the user.
「lock_strategy」 lock the user account by :failed_attempts or :none.
「unlock_strategy」 unlock the user account by :time, :email, :both or :none.
「unlock_in」 the time you want to lock the user after to lock happens. Only available when unlock_strategy is :time or :both.
「unlock_keys」 is the keys you want to use when locking and unlocking an account.
Lockableは、「devise」に次のオプションを追加します
「maximum_attempts」ユーザーをブロックする前に受け入れる必要のある試行回数。
「lock_strategy」は、:failed_attemptsまたは:noneでユーザーアカウントをロックします。
「unlock_strategy」は、:time、:email、:both、または:noneでユーザーアカウントのロックを解除します。
「unlock_in」は、ロックした後にユーザーをロックしたい時間が発生します。 Unlock_strategyが:timeまたは:bothの場合にのみ使用できます。
「unlock_keys」は、アカウントをロックおよびロック解除するときに使用するキーです。
lockableを導入することでクラスにいくつかメソッドが生えるようなので、一つずつコンソールで実行して確認してみます。
maximum_attempts
$ User.maximum_attempts => 20
maximum_attemptsの値は、config/initializers/devise.rb で変更することができます。
config/initializers/devise.rb # Number of authentication tries before locking an account if lock_strategy # is failed attempts. # config.maximum_attempts = 20 # 訳: lock_strategyが失敗した場合に、アカウントをロックするまでの認証試行回数
デフォルトでは20で、アプリケーションの要件に応じて変更が可能です。
lock_strategy
$ User.lock_strategy => :failed_attempts
こちらもconfig/initializers/devise.rbで変更が可能です。
# config/initializers/devise.rb # Defines which strategy will be used to lock an account. # :failed_attempts = Locks an account after a number of failed attempts to sign in. # :none = No lock strategy. You should handle locking by yourself. # config.lock_strategy = :failed_attempts # 訳: アカウントをロックするために使用される戦略を定義します。 # :failed_attempts = ログインに何度も失敗した後、アカウントをロックします。 # :none = ロック戦略なし。 ロックは自分で処理する必要があります。
デフォルトではfailed_attemptsが設定されています。
unlock_strategy
$ User.unlock_strategy => :both
こちらもconfig/initializers/devise.rbで変更が可能です。
# config/initializers/devise.rb # Defines which strategy will be used to unlock an account. # :email = Sends an unlock link to the user email # :time = Re-enables login after a certain amount of time (see :unlock_in below) # :both = Enables both strategies # :none = No unlock strategy. You should handle unlocking by yourself. # config.unlock_strategy = :both # 訳: アカウントのロックを解除するために使用する戦略を定義します。 # :email =ユーザーの電子メールにロック解除リンクを送信します # :time =一定時間後にログインを再度有効にします(以下の:unlock_inを参照) # :both =両方の戦略を有効にします # :none =ロック解除戦略はありません。 ロック解除は自分で行う必要があります。
デフォルトではbothが設定されています。
unlock_in
$ User.unlock_in => 1 hour $ User.unlock_in.class => ActiveSupport::Duration
config/initializers/devise.rbで変更可
# config/initializers/devise.rb # Time interval to unlock the account if :time is enabled as unlock_strategy. # config.unlock_in = 1.hour # 訳: :timeがunlock_strategyとして有効になっている場合に、アカウントのロックを解除する時間間隔。
デフォルトでは1時間で設定されています。
unlock_keys
$ User.unlock_keys => [:email]
# config/initializers/devise.rb # Defines which key will be used when locking and unlocking an account # config.unlock_keys = [:email] # 訳: アカウントをロックおよびロック解除するときに使用するキーを定義します。
デフォルトでは:emailというSymbolが入った配列が設定されています。
ソースコードを追ってみる
valid_for_authentication?メソッドが処理の起点っぽいので、まずはこのメソッドから読んでみます。
valid_for_authentication?
# Overwrites valid_for_authentication? from Devise::Models::Authenticatable # for verifying whether a user is allowed to sign in or not. If the user # is locked, it should never be allowed. # 訳: Devise :: Models :: Authenticationからvalid_for_authentication? を上書きして、ユーザーがサインインを許可されているかどうかを確認します。ユーザーがロックされている場合、ログインは許可されるべきではありません。 def valid_for_authentication? return super unless persisted? && lock_strategy_enabled?(:failed_attempts) # Unlock the user if the lock is expired, no matter # if the user can login or not (wrong password, etc) unlock_access! if lock_expired? if super && !access_locked? true else increment_failed_attempts if attempts_exceeded? lock_access! unless access_locked? else save(validate: false) end false end end
なるほど、元のメソッドをオーバーライドしたメソッドだったんですね。
一行ずつ読み解いて行きます。
return super unless persisted? && lock_strategy_enabled?(:failed_attempts)
selfが保存されておらず、failed_attemptsがenabledになっていない場合、オーバーライド元のメソッドを実行します。
# Unlock the user if the lock is expired, no matter # if the user can login or not (wrong password, etc) # 訳: ユーザーがログインできるかどうか(パスワードが間違っているなど)に関係なく、 ロックの有効期限が切れている場合は、ユーザーのロックを解除します。 unlock_access! if lock_expired?
コメントアウト部分を見ればどんな処理なのか想像が付きますが、unlock_access!メソッドとlock_expired?メソッドを見に行ってみましょう。
unlock_access!
# Unlock a user by cleaning locked_at and failed_attempts. # 訳: locked_atとfailed_attemptsをクリーニングして、ユーザーのロックを解除します。 def unlock_access! self.locked_at = nil self.failed_attempts = 0 if respond_to?(:failed_attempts=) self.unlock_token = nil if respond_to?(:unlock_token=) save(validate: false) end
locked_atをnilに、failed_attemptsカラムが定義されていればfailed_attemptsを0に、unlock_tokenカラムが定義されていればunlock_tokenをnilにし、
バリデーションをスキップしてレコードを保存していました。
lock_expired?
# Tells if the lock is expired if :time unlock strategy is active # 訳: timeロックストラテジーが有効な場合にロックが期限切れかどうかを返します。 def lock_expired? if unlock_strategy_enabled?(:time) locked_at && locked_at < self.class.unlock_in.ago else false end end
:timeがunlock strategyとして有効になっている場合、locked_atが存在し、unlock_inの設定値を見に行きながら、ロックが期限切れかどうかを返すメソッドでした。
access_locked?
if super && !access_locked? true else increment_failed_attempts if attempts_exceeded? lock_access! unless access_locked? else save(validate: false) end false end
オーバーライド元のメソッドを呼び出して true だった場合 かつ、access_locked?の結果がfalseだった場合に、trueを返し、そうでない場合、failed_attemptsの値を更新し、failed_attemptsが一定の回数を超えていた場合、アカウントをロックしています。(既にロックされている場合を除く)
failed_attemptsが一定の回数を超えていなかった場合は、バリデーションをskipしつつレコードを保存しています。
まずはaccess_locked?メソッドを見てみましょう。
# Verifies whether a user is locked or not. # 訳: ユーザーがロックされているかどうかを確認します。 def access_locked? !!locked_at && !lock_expired? end
locked_atの値があって、かつ先ほど見たlock_expired?がfalseの場合、trueを返すメソッドになっていました。
increment_failed_attempts
次にincrement_failed_attemptsメソッドを見てみます。
def increment_failed_attempts self.class.increment_counter(:failed_attempts, id) reload end
メソッド名の通り、failed_attemptsの値を1増加させるメソッドでした。
attempts_exceeded?
次にattempts_exceeded?メソッドを見てみます。
def attempts_exceeded? self.failed_attempts >= self.class.maximum_attempts end
selfのfailed_attemptsが、deviseを利用しているモデルのmaximum_attempts以上だった場合にtrueを返すメソッドでした。
lock_access!
# Lock a user setting its locked_at to actual time. # * +opts+: Hash options if you don't want to send email # when you lock access, you could pass the next hash # `{ send_instructions: false } as option`. # 訳: ユーザーのlocked_atを実際の時間に設定してユーザーをロックします。 # opts: ハッシュオプションアクセスをロックしたときにメールを送信したくない場合は、 `{send_instructions:false}をオプション`として渡すことができます。 def lock_access!(opts = { }) self.locked_at = Time.now.utc if unlock_strategy_enabled?(:email) && opts.fetch(:send_instructions, true) send_unlock_instructions else save(validate: false) end end
これで一通り valid_for_authentication? メソッドは読めました。
その他のメソッド
lockable.rbには他にもメソッドが定義されていたので、集中力が続く限り見ていきます。
reset_failed_attempts!
# Resets failed attempts counter to 0. # 訳: failed_attemptsを0にリセットします。 def reset_failed_attempts! if respond_to?(:failed_attempts) && !failed_attempts.to_i.zero? self.failed_attempts = 0 save(validate: false) end end
failed_attemptsカラムが定義されていて、値が0でなければ、failed_attemptsカラムの値を0にし、バリデーションをスキップしてレコードを保存しています。
ログイン時のhooksが呼び出し元のようです。
# devise/lib/devise/hooks/lockable.rb # frozen_string_literal: true # After each sign in, if resource responds to failed_attempts, sets it to 0 # This is only triggered when the user is explicitly set (with set_user) Warden::Manager.after_set_user except: :fetch do |record, warden, options| if record.respond_to?(:reset_failed_attempts!) && warden.authenticated?(options[:scope]) record.reset_failed_attempts! end end
send_unlock_instructions
# Send unlock instructions by email def send_unlock_instructions raw, enc = Devise.token_generator.generate(self.class, :unlock_token) self.unlock_token = enc save(validate: false) send_devise_notification(:unlock_instructions, raw, {}) raw end
unlock_token向けのtokenを生成して、レコードを保存しつつメールを送信するメソッドになっています。
send_devise_notification
def send_devise_notification(notification, *args) message = devise_mailer.send(notification, self, *args) # Remove once we move to Rails 4.2+ only. if message.respond_to?(:deliver_now) message.deliver_now else message.deliver end end
ここが実際にメール送信を呼び出すメソッドですね。
疲れたのでここまでにします、、、
まとめ
lockableはvalid_for_authentication?をオーバーライドして、アカウントがロックされているかの確認や、認証に失敗した場合に失敗回数をカウントし、アカウントロックをかけたりしているということは最低限理解しておきたい。