deviseのencrypted_passwordに値が保存される仕組みを調べてみた

こんにちは!kossyです!




今回はdeviseで認証機能を利用する際に必要になるカラムである、「encrypted_password」カラム に値が保存される仕組みを調べてみたので、

備忘録としてブログに残してみたいと思います。




環境
Ruby 3.0.3
Rails 6.0.4
devise 4.8.1





なお、既にdeviseを利用しているUserモデルが定義されていて、デバッグ用のGem2種類(pry-rails & pry-byebug)がinstallされていることとします。


ユーザー作成時の動作を見てみる

まずはコンソールでユーザーを作成する際の挙動を見てみます。

# Userクラスのソースコード
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
end

# コンソール
$ user = User.create(email: 'sample-user@example.or.jp', password: 'test1234')
   (0.5ms)  BEGIN
  User Exists? (11.4ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "sample-user@example.or.jp"], ["LIMIT", 1]]
  User Create (9.0ms)  INSERT INTO "users" ("email", "encrypted_password", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["email", "sample-user@example.or.jp"], ["encrypted_password", "$2a$12$OlpguVbwkOb6BuZ4dxXV8.XP/Byqb9TSu6ot23cwpHssOGSrIS6Vu"], ["created_at", "2021-12-26 07:35:11.543708"], ["updated_at", "2021-12-26 07:35:11.543708"]]
   (3.8ms)  COMMIT

Userクラスに何か特別なメソッドを生やしたわけではありませんが、レコードのcreate時にeccrypted_passwordカラム に値が入っていることがわかります。

なので、deviseのmoduleのいずれかのソースコードを確認すればよさそうです。

database_authenticatableでencrypted_passwordの挿入を行なっていそうなので、見に行ってみます。

database_authenticatable

github.com

コメントアウト部分を訳してみます。

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.

Authenticatable Moduleは、パスワードのハッシュ化と、サインイン中のユーザーの信頼性の検証を担当します。

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

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb

password=メソッドが呼ばれた際にencrypted_passwordに値が入るので、ここでこの記事を終わりにしてもいいんですが、それでは味気ないので、

もっと深くコードを追ってみましょう。


password=

github.com

# Generates a hashed password based on the given value.
# For legacy reasons, we use `encrypted_password` to store
# the hashed password.

指定された値に基づいてハッシュ化されたパスワードを生成します。
歴史的な理由から、ハッシュ化されたパスワードを保存するために `encrypted_password`を使用します。

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

コメントアウトの「For legacy reasons」が気になりますね。

git blameでcommitメッセージを見てみましょう。

Change encryption for hashing in the documentation.
Throughout the documentations, we are using 'encrypt' incorrectly.
Encrypt means that someone will eventually decrypt the message,
which is obviously not the case for Devise.

I'm changing the docs to use 'hashing' instead.

However, I left the database field as `encrypted_password` for now.
I'll update the db field in an upcoming PR.

ドキュメントのハッシュの暗号化を変更します。
ドキュメント全体を通して、「encrypt」を誤って使用しています。
暗号化とは、誰かが最終的にメッセージを復号化することを意味します。
これは明らかにDeviseには当てはまりません。

代わりに「ハッシュ」を使用するようにドキュメントを変更しています。

ただし、今のところ、データベースフィールドは `encrypted_password`のままにしておきます。
今後のPRでdbフィールドを更新します。

出典: Change encryption for hashing in the documentation. · heartcombo/devise@c4b4411 · GitHub

なるほど、encryptの意味を誤用しているから、本当はhashed_passwordにカラム名を変更したいけど、

このcommitでは一旦encrypted_passwordにしておいたとのことです。

コメントアウトとcommitメッセージの意図が汲み取れたところで、ソースコードを見てみます。

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

引数のnew_passwordをインスタンス変数とし、@passwordがpresentであれば、password_digestをencrypted_passwordの値としているようです。

password_digestメソッドを見る必要がありそう。


password_digest

github.com

# 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

コメントアウト部分を訳してみます。

Hashes the password using bcrypt. Custom hash functions should override this method to apply their own algorithm.

bcryptを使用してパスワードをハッシュ化します。 カスタムハッシュ関数は、独自のアルゴリズムを適用するためにこのメソッドをオーバーライドする必要があります。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb

パスワードハッシュアルゴリズムであるbcryptを使用して引数のpasswordをハッシュ化してくれるメソッドのようです。

コンソールで試してみます。

> user = User.first

> password = 'test1234'

> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$N3Vh/XXNu6rHPMLxBd1LMeSzH.c3rQWl6EndVZQotaQRIRU3gmrDy"

> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$A4G7CAQb50llKJ.oyJauEu6J6PoK7UZrNtN7In9AicIvyPIf5fZ5y"

# 何度実行しても同じ値は返らない
> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$klOb/T0Z/ryo9U.Z2SFbPO8f7zOYs47Om/Ea9GGncb53OHve2E6Ya"

passwordは一緒でも同じ値は返らないことが確認できますね。


regisrations_controller.rbのcreateアクションの動作を追ってみる

https://github.com/heartcombo/devise/blob/main/app/controllers/devise/regisrations_controller.rbgithub.com

どんな処理を経て最終的にユーザーが作成されるのかもついでに見てみます。

  # POST /resource
  def create
    build_resource(sign_up_params)

    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

build_resource

github.com

# Build a devise resource passing in the session. Useful to move
# temporary session data to the newly created user.

セッションで渡すデバイスリソースを構築します。
 一時的なセッションデータを新しく作成したユーザーに移行するのに便利です。

def build_resource(hash = {})
  self.resource = resource_class.new_with_session(hash, session)
end

resource_classはサインアップしようとしているモデルのクラスが返ります。

次にnew_with_sessionメソッドを確認してみます。

new_with_session

github.com

# A convenience method that receives both parameters and session to
# initialize a user. This can be used by OAuth, for example, to send
# in the user token and be stored on initialization.
#
# By default discards all information sent by the session by calling
# new with params.
def new_with_session(params, session)
  new(params)
end

コメントアウト部分を訳してみます。

A convenience method that receives both parameters and session to initialize a user.
This can be used by OAuth, for example, to send in the user token and be stored on initialization.
By default discards all information sent by the session by calling new with params.

ユーザーを初期化するためにパラメーターとセッションの両方を受け取る便利なメソッド。
これは、たとえば、ユーザートークンを送信し、初期化時に保存するためにOAuthで使用できます。
デフォルトでは、paramsを使用してnewを呼び出すことにより、セッションによって送信されたすべての情報を破棄します。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/registerable.rb

メソッド名の通り、パラメーターとセッションの両方を受け取って処理を行うメソッドですね。

REPLで確認してみます。

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/registerable.rb:22 Devise::Models::Registerable::ClassMethods#new_with_session:

    21: def new_with_session(params, session)
 => 22:   new(params)
    23: end

[1] pry(User)> params
=> {"email"=>"test-user-1@example.or.jp", "password"=>"test1234", "password_confirmation"=>"141b3ccef657"}
[2] pry(User)> session
=> #<ActionDispatch::Request::Session:0x00007f300842df80 ...>
[3] pry(User)> session.to_h
=> {"session_id"=>"b71549ad67aa9d67e27aa04fea2c0b37", "_csrf_token"=>"KbKDaYOjWm7jMLPfqctmdhG3ImpMGoQ4y9JX89plxLc="}

paramsはユーザー登録時に入力した値が格納されていて、sessionはActionDispatch::Request::Sessionクラスのインスタンスでした。

newメソッドは ActiveRecord::Inheritance::ClassMethods#new が実行されるようです。


resource=

nextメソッドで処理を進めるとresource=メソッドがcallされました。

From: /usr/local/bundle/gems/devise-4.8.0/app/controllers/devise_controller.rb:95 DeviseController#resource=:

    94: def resource=(new_resource)
 => 95:   instance_variable_set(:"@#{resource_name}", new_resource)
    96: end

このメソッドが実行されると、以降は resource でユーザーを取得することができます。

ここでbuild_resourceの処理は終わりでした。


まとめ

deviseのencrypted_passwordに値が保存される仕組みを一言で言うと、

「password属性に値が入るタイミングでecnrypted_passwordカラムにBCryptアルゴリズムによってハッシュ化された文字列が代入される」

でしたね。

また、「encryptの意味を誤用しているからencrypted_passwordという命名を変えた方がいい」という話があることは知りませんでした。

上記の変更が2014年に加わっていて、その後encrypted_passwordという名前が変わっていないことを考えると、

既にdeviseを使ったアプリケーションが何万とある中で、後方互換性を維持したまま変更を加えるのが大変なんでしょう、、、

命名には細心の注意を払うようにしましょう、、、