大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

【Devise】Database authenticatable

Deviseによるモデル設定

こんにちは!大ちゃんの駆け出し技術ブログです。

最近deviseをなんとなくで使い始めましたが、本当に多機能で理解が追いついていません。なので、何回かに分けて記事にしようと思います。

今回はDeviseのモデル設定についてです。公式でいうところのConfiguring Modelsの項目です。

github.com

Deviseを使ってUserモデルを作成しようとすると、モデルとマイグレーションファイルに複数の見たことのない文字が出ると思います。

  • モデル
# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
end
class AddDeviseToUsers < ActiveRecord::Migration[5.2]
  def self.up
    change_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      # t.integer  :sign_in_count, default: 0, null: false
      # t.datetime :current_sign_in_at
      # t.datetime :last_sign_in_at
      # t.inet     :current_sign_in_ip
      # t.inet     :last_sign_in_ip

      ## Confirmable
      # t.string   :confirmation_token
      # t.datetime :confirmed_at
      # t.datetime :confirmation_sent_at
      # t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at
・
・

~ableの文字がたくさんあります。これらはいったいどのような設定をUserモデルに追加しているのでしょうか。

Qiitaで1000LGTM以上のdeviseに関する記事でが以下のような表で説明しています。

https://i.gyazo.com/3e3d924323c55536d2b96edb5d1789d0.png

qiita.com

確かに簡潔にまとめられていますが詳しい仕組みなどがありません。ですので、これから複数の記事に渡って、deviseのモデル設定の仕組みを自分なりに解説していこうと思います。

Database authenticatable(データベースの認証)

公式ページがありましたのでそちらを見ていきます。

heartcombo/devise

# frozen_string_literal: true

require 'devise/strategies/database_authenticatable'

module Devise
  module Models
    # Authenticatable Module, responsible for hashing the password and
    # validating the authenticity of a user while signing in.
    #
    # This module defines a `password=` method. This method will hash the argument
    # and store it in the `encrypted_password` column, bypassing any pre-existing
    # `password` column if it exists.
    #
    # == Options
    #
    # DatabaseAuthenticatable adds the following options to devise_for:
    #
    #   * +pepper+: a random string used to provide a more secure hash. Use
    #     `rails secret` to generate new keys.
    #
    #   * +stretches+: the cost given to bcrypt.
    #
    #   * +send_email_changed_notification+: notify original email when it changes.
    #
    #   * +send_password_change_notification+: notify email when password changes.
    #
    # == Examples
    #
    #    User.find(1).valid_password?('password123')         # returns true/false
    #
    module DatabaseAuthenticatable
      extend ActiveSupport::Concern

      included do
        after_update :send_email_changed_notification, if: :send_email_changed_notification?
        after_update :send_password_change_notification, if: :send_password_change_notification?

        attr_reader :password, :current_password
        attr_accessor :password_confirmation
      end

      def initialize(*args, &block)
        @skip_email_changed_notification = false
        @skip_password_change_notification = false
        super 
      end

      # Skips sending the email changed notification after_update
      def skip_email_changed_notification!
        @skip_email_changed_notification = true
      end

      # Skips sending the password change notification after_update
      def skip_password_change_notification!
        @skip_password_change_notification = true
      end

      def self.required_fields(klass)
        [:encrypted_password] + klass.authentication_keys
      end

      # Generates a hashed password based on the given value.
      # For legacy reasons, we use `encrypted_password` to store
      # the hashed password.
      def password=(new_password)
        @password = new_password
        self.encrypted_password = password_digest(@password) if @password.present?
      end

      # Verifies whether a password (ie from sign in) is the user password.
      def valid_password?(password)
        Devise::Encryptor.compare(self.class, encrypted_password, password)
      end

      # Set password and password confirmation to nil
      def clean_up_passwords
        self.password = self.password_confirmation = nil
      end

      # Update record attributes when :current_password matches, otherwise
      # returns error on :current_password.
      #
      # This method also rejects the password field if it is blank (allowing
      # users to change relevant information like the e-mail without changing
      # their password). In case the password field is rejected, the confirmation
      # is also rejected as long as it is also blank.
      def update_with_password(params, *options)
        if options.present?
          ActiveSupport::Deprecation.warn <<-DEPRECATION.strip_heredoc
            [Devise] The second argument of `DatabaseAuthenticatable#update_with_password`
            (`options`) is deprecated and it will be removed in the next major version.
            It was added to support a feature deprecated in Rails 4, so you can safely remove it
            from your code.
          DEPRECATION
        end

        current_password = params.delete(:current_password)

        if params[:password].blank?
          params.delete(:password)
          params.delete(:password_confirmation) if params[:password_confirmation].blank?
        end

        result = if valid_password?(current_password)
          update(params, *options)
        else
          assign_attributes(params, *options)
          valid?
          errors.add(:current_password, current_password.blank? ? :blank : :invalid)
          false
        end

        clean_up_passwords
        result
      end

      # Updates record attributes without asking for the current password.
      # Never allows a change to the current password. If you are using this
      # method, you should probably override this method to protect other
      # attributes you would not like to be updated without a password.
      #
      # Example:
      #
      #   def update_without_password(params, *options)
      #     params.delete(:email)
      #     super(params)
      #   end
      #
      def update_without_password(params, *options)
        if options.present?
          ActiveSupport::Deprecation.warn <<-DEPRECATION.strip_heredoc
            [Devise] The second argument of `DatabaseAuthenticatable#update_without_password`
            (`options`) is deprecated and it will be removed in the next major version.
            It was added to support a feature deprecated in Rails 4, so you can safely remove it
            from your code.
          DEPRECATION
        end

        params.delete(:password)
        params.delete(:password_confirmation)

        result = update(params, *options)
        clean_up_passwords
        result
      end

      # Destroy record when :current_password matches, otherwise returns
      # error on :current_password. It also automatically rejects
      # :current_password if it is blank.
      def destroy_with_password(current_password)
        result = if valid_password?(current_password)
          destroy
        else
          valid?
          errors.add(:current_password, current_password.blank? ? :blank : :invalid)
          false
        end

        result
      end

      # A callback initiated after successfully authenticating. This can be
      # used to insert your own logic that is only run after the user successfully
      # authenticates.
      #
      # Example:
      #
      #   def after_database_authentication
      #     self.update_attribute(:invite_code, nil)
      #   end
      #
      def after_database_authentication
      end

      # A reliable way to expose the salt regardless of the implementation.
      def authenticatable_salt
        encrypted_password[0,29] if encrypted_password
      end

      if Devise.activerecord51?
        # Send notification to user when email changes.
        def send_email_changed_notification
          send_devise_notification(:email_changed, to: email_before_last_save)
        end
      else
        # Send notification to user when email changes.
        def send_email_changed_notification
          send_devise_notification(:email_changed, to: email_was)
        end
      end

      # Send notification to user when password changes.
      def send_password_change_notification
        send_devise_notification(:password_change)
      end

    protected

      # Hashes the password using bcrypt. Custom hash functions should override
      # this method to apply their own algorithm.
      #
      # See https://github.com/heartcombo/devise-encryptable for examples
      # of other hashing engines.
      def password_digest(password)
        Devise::Encryptor.digest(self.class, password)
      end

      if Devise.activerecord51?
        def send_email_changed_notification?
          self.class.send_email_changed_notification && saved_change_to_email? && !@skip_email_changed_notification
        end
      else
        def send_email_changed_notification?
          self.class.send_email_changed_notification && email_changed? && !@skip_email_changed_notification
        end
      end

      if Devise.activerecord51?
        def send_password_change_notification?
          self.class.send_password_change_notification && saved_change_to_encrypted_password? && !@skip_password_change_notification
        end
      else
        def send_password_change_notification?
          self.class.send_password_change_notification && encrypted_password_changed? && !@skip_password_change_notification
        end
      end

      module ClassMethods
        Devise::Models.config(self, :pepper, :stretches, :send_email_changed_notification, :send_password_change_notification)

        # We assume this method already gets the sanitized values from the
        # DatabaseAuthenticatable strategy. If you are using this method on
        # your own, be sure to sanitize the conditions hash to only include
        # the proper fields.
        def find_for_database_authentication(conditions)
          find_for_authentication(conditions)
        end
      end
    end
  end
end

めちゃくちゃに長いですね、、、

重要そうなところだけピックアップして解説します。

まず、いちばん上にコメントアウトでざっくりと概要が書かれています。

Authenticatable Module, responsible for hashing the password and validating the authenticity of a user while signing in.

訳としては、「サインイン時にパスワードのハッシュ化とユーザーの真正性の検証を行う」でしょうか。

ログイン機能としてとても重要なユーザーの真正性を行ってくれるようです。

上記の記載のすぐ下に以下の文があります。

This module defines a password= method. This method will hash the argument and store it in the encrypted_password column, bypassing any pre-existing password column if it exists.

訳: このモジュールは password= メソッドを定義しています。このメソッドは引数をハッシュ化して encrypted_password カラムに格納し、既存の password カラムがある場合はそれをバイパスします。

このpassword=メソッドはモジュールの中に定義されていました。

def password=(new_password)
  @password = new_password
  self.encrypted_password = password_digest(@password) if @password.present?
end

引数であるnew_password@passwordに格納し、@passwordが空でなければ、password_digestメソッドによりハッシュ化される仕組みのようです。password_digestについても同じmodule内にあるので見てみます。

# Hashes the password using bcrypt. Custom hash functions should override
# this method to apply their own algorithm.
#
#
def password_digest(password)
  Devise::Encryptor.digest(self.class, password)
end

引数であるpasswordは先ほどの@passwordですね。第一引数であるself.classはこのdeviseで定義しているモデルです。Deviseでモデル作成時にUserとすれば、self.classはUserとなります。Devise::Encryptor.digest(self.class, password)はいったい何をしているのでしょうか。

Devise::EncryptorとあるようにDeviseのmoduleの中で定義されているEncryptorというmoduleに記載がありました。

github.com

# frozen_string_literal: true

require 'bcrypt'

module Devise
  module Encryptor
    def self.digest(klass, password) # <= この部分!!
      if klass.pepper.present?
        password = "#{password}#{klass.pepper}"
      end
      ::BCrypt::Password.create(password, cost: klass.stretches).to_s
    end

    def self.compare(klass, hashed_password, password)
      return false if hashed_password.blank?
      bcrypt   = ::BCrypt::Password.new(hashed_password)
      if klass.pepper.present?
        password = "#{password}#{klass.pepper}"
      end
      password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
      Devise.secure_compare(password, hashed_password)
    end
  end
end

self.digest(klass, password)klassself.classが入っています。なぜclassとしないのかはわかりません笑

この部分については下記記事で説明されていました。

qiita.com

"#{password}#{klass.pepper}"については、平文パスワードにあらかじめ設定されている文字列(pepper)をくっつけ、結合された文字列をハッシュ化を行います。pepperは平文パスワードにあらかじめ設定されている文字列とあることから、登録済みのpasswordでないと設定されていないということでしょう。ですのでklass.pepper.present?とすることで、既に登録済みのパスワードがあるモデル場合trueを返すようにしています。

逆にこの分岐でfalseになるということはこのモデルはパスワード登録を行なっていないということになるので、パスワードのハッシュ化moduleであるBCryptを使用しているということですね。このようにモデルのカラムにパスワードをハッシュ化してデータベースに保存する仕組みができています。

次に検証部分ですが、それは以下のメソッドになるかなと思います。(最初に記載した長いmoduleの中で定義されています。)

# Verifies whether a password (ie from sign in) is the user password.
def valid_password?(password)
  Devise::Encryptor.compare(self.class, encrypted_password, password)
end

「パスワード(サインイン時のもの)がユーザーパスワードであるかどうかを検証」とあるように、渡されたパスワードを検証するメソッドです。内部にあるこちらのメソッド(Devise::Encryptor.compare)は先ほど参照したEncryptorモジュールの中にあります。

  def self.compare(klass, hashed_password, password)
    return false if hashed_password.blank?
    bcrypt   = ::BCrypt::Password.new(hashed_password)
    if klass.pepper.present?
      password = "#{password}#{klass.pepper}"
    end
    password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
    Devise.secure_compare(password, hashed_password)
  end

hashed_password.blank?で渡されたハッシュ化されたパスワードが空値ならすぐにreturnを返して処理を終了させますね。それ以降の処理についてはこのモジュール内からだとわかりにくいのですが、Devise.secure_compare(password, hashed_password)で引数として渡されたハッシュ化されたパスワード(hashed_password)と既に登録済みのパスワードと平文パスワードにあらかじめ設定されている文字列を結合したもの(password)を比較しているものと思います。

このようにして、データベースを用いてユーザーのパスワード検証を行っているのがDatabase authenticatableだと言えます。