アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみる

こんにちは!kossyです!




今回は、アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4
devise 4.8.0



なお、コードの説明の前提として、既にdeviseが導入済みで、deviseを利用しているUserというモデルが定義されていることとします。


github.com




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時間)後にユーザーのロックを自動的に解除します。

電子メールと時間の両方の戦略を使用するようにロック可能を設定することも可能です。

出典: devise/lockable.rb at master · heartcombo/devise · GitHub

「特定の回数ログインに失敗したユーザーのアクセスをブロックする機能」
「ロック解除にはメールのリンクを時間内に踏む必要がある」

この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」は、アカウントをロックおよびロック解除するときに使用するキーです。

出典: devise/lockable.rb at master · heartcombo/devise · GitHub

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がnilかつ先ほど見た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?をオーバーライドして、アカウントがロックされているかの確認や、認証に失敗した場合に失敗回数をカウントし、アカウントロックをかけたりしているということは最低限理解しておきたい。