5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「ログインする」ってなに?

新入社員に Rails 学習教材を使用して、 sorcery を使用したログイン機能実装を練習アプリに実装していただきました。login メソッドの話になったのですが、教材やネットでひっかかる記事には login メソッドの中の動きについて「ログインしてくれます」としか書かれておらず、物足りなさを感じたので、login メソッドを読んでいきます。

まずは簡単につくる

環境

以下のバージョンで確認していきます。

  • rails 7.1.3
  • ruby 3.2.2
  • sorcery 0.17.0

sorcery は現在メンテナンスされておりません

Sorcery is currently unmaintained.

https://github.com/Sorcery/sorcery

バージョンによっては互換性が維持されている保証はなく、将来的に動かなくなるリスクもあります。

rails new

まずはrails newしてアプリをつくってみます。

rails _7.1.3_ new sorcery_login_sample -d sqlite3 \
  --skip-javascript \
  --skip-hotwire \
  --skip-action-mailbox \
  --skip-action-text \
  --skip-active-storage \
  --skip-test \
  --skip-system-test \
  --skip-bootsnap \
  --skip-jbuilder \
  --skip-turbolinks \
  --skip-sprockets

必要最小限にするためいろいろ skip しています。

オプション 省略対象
--skip-javascript app/javascript/ 等
--skip-hotwire Turbo / Stimulus
--skip-action-mailbox メール受信機能(使わない)
--skip-action-text RichText (Trix)
--skip-active-storage 画像アップロード等
--skip-test test/ ディレクトリ全体
--skip-system-test system test(Capybara 等)
--skip-bootsnap 起動高速化(学習には不要)
--skip-jbuilder JSON テンプレート生成(API でなければ不要)
--skip-turbolinks turbolinks 無効化
--skip-sprockets Sprockets(アセット)を無効化
cd sorcery_login_sample
bundle install
bin/rails db:create

これでアプリはまずは完成です。

rails sして、ブラウザで http://localhost:3000 を開いて、rails の初期画面で「rails version: 7.1.5.1 Ruby version: ruby 3.2.2」が表示されていることを確認してください。

sorcery のセットアップ

Gemfile に以下を記述。

gem 'sorcery', '~> 0.17.0'

インストールし、セットアップします。

bundle install
bin/rails generate sorcery:install
bin/rails db:migrate

config/initializers/sorcery.rb(初期設定ファイル)と User モデル、マイグレーションファイルができ、テーブルに反映されます

app/models/user.rb
class User < ApplicationRecord
  authenticates_with_sorcery!
end
db/migrate/20250629070330_sorcery_core.rb
class SorceryCore < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email,            null: false, index: { unique: true }
      t.string :crypted_password
      t.string :salt

      t.timestamps                null: false
    end
  end
end

最低限のログイン機能を実装

1. セッション用コントローラ作成

SessionsController を作成します。

bin/rails generate controller Sessions new

SessionsController を以下に編集します。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    if login(params[:email], params[:password])
      redirect_to root_path, notice: 'ログイン成功'
    else
      flash.now[:alert] = 'ログイン失敗'
      render :new
    end
  end

  def destroy
    logout
    redirect_to login_path, notice: 'ログアウトしました'
  end
end

2. ログイン画面を作成

ログインフォームを作成します。

app/views/sessions/new.html.erb
<h1>ログイン</h1>

<%= form_with url: login_path, method: :post do %>
  <div>
    <%= label_tag :email %><br>
    <%= text_field_tag :email %>
  </div>

  <div>
    <%= label_tag :password %><br>
    <%= password_field_tag :password %>
  </div>

  <%= submit_tag "ログイン" %>
<% end %>

フラッシュメッセージを出せるように、application.html.erb<body>要素内を以下のように追記しておきます。

app/views/layouts/application.html.erb
  <body>
    <% flash.each do |type, message| %>
      <div class="flash <%= type %>"><%= message %></div>
    <% end %>

    <%= yield %>
  </body>

3. ルーティングを追加

/loginで一連のログインの動きができるようにしておきます。

config/routes.rb
rails.application.routes.draw do
  root 'sessions#new'

  get    '/login',  to: 'sessions#new'
  post   '/login',  to: 'sessions#create'
  delete '/logout', to: 'sessions#destroy'
end

4. ユーザーを 1 件作る

rails コンソールを使用して、ユーザーを 1 件だけ登録しておきます。

bin/rails console

test@example.com / passwordとしておきます。

User.create(email: "test@example.com", password: "password")

User.createをすると Sorcery が自動で以下の2つの値を作成してくれます:

crypted_password(暗号化されたパスワード)

  • パスワードを「ハッシュ関数」という特殊な変換方法で暗号化した値
  • 元のパスワード "password" → 暗号化 → "$2a$10$abc123..."のような文字列
  • 重要: この変換は「一方向」で、暗号化された値から元のパスワードは復元できない
  • データベースが漏洩しても、元のパスワードが分からないので安全

salt(ソルト:ランダム文字列)

  • パスワードに追加するランダムな文字列
  • 例:パスワード "password" + ソルト "abc123""passwordabc123"
  • 目的: 同じパスワードでも毎回異なる暗号化結果にする
  • 「レインボーテーブル攻撃」(事前計算されたパスワード辞書での攻撃)を防ぐ

これらの仕組みにより、パスワードが安全に保存されます。

5. 動作確認

アプリを起動して、

bin/rails server

ブラウザで http://localhost:3000 を開いて、test@example.com / password でログインできることを確認してください。メールアドレスとパスワードが正しい場合は「ログイン成功」のフラッシュメッセージが、間違っている場合は「ログイン失敗」のフラッシュメッセージがでます。

login を読む

login の定義場所を探す

以下のようにしてsource_locationメソッドを使用すると、メソッドの定義場所がわかります。

rails コンソールにて、

bin/rails console

SessionsControllerに記載した、loginメソッドは、Controller のインスタンスメソッドです。
ruby のsource_locationメソッドによって、login メソッドの定義場所(sorcery gem の内部)を知ることができます。

ApplicationController.instance_method(:login).source_location

以下のように、ファイルパス + 行番号 がでます。

出力例:

["/Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb", 37]

今私達が確認したい login メソッドはlib/sorcery/controller.rbというファイルの 37 行目であることがわかります。以下ですね。

VScode 上で読みたい場合は

例:

code /Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb

で VScode を立ち上げてもいいかもしれません。

login がやっていること

さて、それでは定義している部分がわかったので、以下、login メソッドがなにをやっているのかを読み解いていきます。

/Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb
      # Takes credentials and returns a user on successful authentication.
      # Runs hooks after login or failed login.
      def login(*credentials)
        @current_user = nil

        user_class.authenticate(*credentials) do |user, failure_reason|
          if failure_reason
            after_failed_login!(credentials)

            yield(user, failure_reason) if block_given?

            # FIXME: Does using `break` or `return nil` change functionality?
            # rubocop:disable Lint/NonLocalExitFromIterator
            return
            # rubocop:enable Lint/NonLocalExitFromIterator
          end

          old_session = session.dup.to_hash
          reset_sorcery_session
          old_session.each_pair do |k, v|
            session[k.to_sym] = v
          end
          form_authenticity_token

          auto_login(user, credentials[2])
          after_login!(user, credentials)

          block_given? ? yield(current_user, nil) : current_user
        end
      end

1. 初期化

lib/sorcery/controller.rb
        @current_user = nil

現在のユーザー情報をリセットします。
rails を触っているとよくでてくる@current_userですね。まずはここでは、@current_usernilを突っ込んでいます。

2. 認証処理

lib/sorcery/controller.rb
        user_class.authenticate(*credentials) do |user, failure_reason|

user_classauthenticateメソッドを呼び出しています。ブロックを渡して認証結果を処理しているようです。
user_classというわからない変数がでてきました。これは、このファイルの下の方で以下のように定義してあります。

lib/sorcery/controller.rb
      def user_class
        @user_class ||= Config.user_class.to_s.constantize
      rescue NameError
        raise ArgumentError, 'You have incorrectly defined user_class or have forgotten to define it in the initializer file (config.user_class = \'User\').'
      end

@user_classがある場合はその値を。ない場合は、Config.user_class.to_s.constantizeを返していますね。
@user_classConfig.user_class.to_s.constantizeを見ていきましょう。その前に、このあたりを理解するためには、このlib/sorcery/controller.rbのはじめのほう L1~19 を少し読んでおく必要があります。

lib/sorcery/controller.rb
module Sorcery
  module Controller
    def self.included(klass)
      klass.class_eval do
        # 省略
      end
      Config.update!
      Config.configure!
    end

::Sorcery::Controller内でself.includedという関数が定義されており、その中で、Config.update!メソッドとConfig.configure!メソッドが呼ばれています。
この、self.included関数はモジュールがインクルードされた時にコールされるメソッドで、Ruby の仕組みとして、include Sorcery::Controllerが実行されると、自動的にSorcery::Controller.includedメソッドが呼ばると思っておいてください。

では、include Sorcery::Controllerが実行される場所ですが、これは、sorcery 内のlib/sorcery/engine.rbで実行されています(sendという別の方法でinclude Sorcery::Controllerが実行されています)。

lib/sorcery/engine.rb
require 'sorcery'
require 'rails'

module Sorcery
  class Engine < rails::Engine
    config.sorcery = ::Sorcery::Controller::Config

    initializer 'extend Controller with sorcery' do
      # 省略
      if defined?(ActionController::Base)
        ActionController::Base.send(:include, Sorcery::Controller) # ← `include Sorcery::Controller`を実行しているのと同じ意味!
        ActionController::Base.helper_method :current_user
        ActionController::Base.helper_method :logged_in?
      end
    end
  end
end

このinitializer 'extend Controller with sorcery' doという部分は rails::Engine が提供するメソッドで、rails 起動プロセスの特定のタイミングでコードを実行するようになっています。流れは以下です。

rails 起動時の流れ

  1. rails アプリケーション起動開始
  2. Gemfile から gem を読み込み
  3. 各 gem の Engine クラスが読み込まれる
  4. ※ ここで Sorcery::Engine も読み込まれる
  5. rails が全ての initializer を収集
  6. 優先順位に従って initializer を実行 ← ※ ここで実行!
  7. アプリケーションコードの読み込み
  8. サーバー起動完了

ここまでわかったことをもう一度まとめると、①rails アプリ起動時にinitializer 'extend Controller with sorcery' doが呼ばれる。②include Sorcery::Controllerが実行される。③Sorcery::Controller.includedが実行される。ということがわかりました。
では続き、self.includedメソッド内の、Config.update!メソッドとConfig.configure!メソッドを見ていきましょう。
まずは、Sorcery::Controller::Configの全体像を把握します。

lib/sorcery/controller/config.rb
module Sorcery
  module Controller
    module Config
      class << self
        attr_accessor :submodules
        # what class to use as the user class.
        attr_accessor :user_class

        # 省略

        def init!
          @defaults = {
            :@user_class                           => nil,
            :@submodules                           => [],
            :@not_authenticated_action             => :not_authenticated,
            :@login_sources                        => Set.new,
            :@after_login                          => Set.new,
            :@after_failed_login                   => Set.new,
            :@before_logout                        => Set.new,
            :@after_logout                         => Set.new,
            :@after_remember_me                    => Set.new,
            :@save_return_to_url                   => true,
            :@cookie_domain                        => nil
          }
        end

        # Resets all configuration options to their default values.
        def reset!
          @defaults.each do |k, v|
            instance_variable_set(k, v)
          end
        end

        def update!
          @defaults.each do |k, v|
            instance_variable_set(k, v) unless instance_variable_defined?(k)
          end
        end

        def user_config(&blk)
          block_given? ? @user_config = blk : @user_config
        end

        def configure(&blk)
          @configure_blk = blk
        end

        def configure!
          @configure_blk.call(self) if @configure_blk
        end
      end

      init!
      reset!
    end
  end
end

:user_classという仮想属性が定義されており、init!によって@user_classに初期値nilが定義されたのが初期状態であることがわかります。

ですが現在、手元で、rails コンソールを使ってuser_classを確認してみると、"User"という文字列が格納されていることが確認できます。

irb(main):001> ::Sorcery::Controller::Config.user_class
=> "User"

実はこれ、我々が書き換えたものです。
再度、bin/rails generate sorcery:installの時にできたファイル、config/initializers/sorcery.rbをよく読んでみます。
なにやら、config.user_class"User"を代入しています。

config/initializers/sorcery.rb
rails.application.config.sorcery.submodules = []

# Here you can configure each submodule's features.
rails.application.config.sorcery.configure do |config| # ← Sorcery::Controller::Config.configure メソッドのこと
  # 省略
  config.user_config do |user|
    # 省略
  end

  config.user_class = "User" # ← ブロック内でuser_classに"User"を定義
end

このブロックはそのまま@configure_blkに代入されます。

lib/sorcery/controller/config.rb
        def configure(&blk)
          @configure_blk = blk # ← 「config.user_class = "User"」含む、ブロックの中身を変数に代入
        end

そして、self.includedメソッド内のConfig.configure!メソッドによって、実際に"User":user_class属性に定義されます。ここまで読んで、rails の起動時にuser_classに文字列"User"が格納されることがわかりました。

lib/sorcery/controller/config.rb
        def configure!
          @configure_blk.call(self) if @configure_blk # ← 実際に config.user_class = "User" を実行
        end

以上から、やっとuser_classメソッドがわかってきます。
現在@user_classnilですので、Config.user_class.to_s.constantizeが入ります。
constantizeは文字列やシンボルを Ruby の定数(クラスやモジュール)に変換するメソッドで、今回は"User"ですので戻り値はUserクラス(Userモデル)になります。

lib/sorcery/controller.rb
      def user_class
        @user_class ||= Config.user_class.to_s.constantize
      rescue NameError
        raise ArgumentError, 'You have incorrectly defined user_class or have forgotten to define it in the initializer file (config.user_class = \'User\').'
      end

さて、user_classメソッドの戻り値がUserクラスであることがわかりました。それでは、続きのauthenticateメソッドを見ていきます。

lib/sorcery/controller.rb
        user_class.authenticate(*credentials) do |user, failure_reason|

このauthenticateメソッドは可変引数*credentialsによって、loginメソッドの引数をすべてそのまま受け取り(今回だとparams[:email], params[:password])、さらにブロック|user, failure_reason|も引き渡されています。

さて、このauthenticateメソッドが定義されている場所ですが、以下になります

irb(main):001> User.method(:authenticate).source_location
=> ["/Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/model.rb", 86]

lib/sorcery/model.rb
      def authenticate(*credentials, &block)
        # credentials = ['test@example.com', 'password']
        # credentials[0] = 'test@example.com'  # メールアドレス
        # credentials[1] = 'password'       # パスワード
        raise ArgumentError, 'at least 2 arguments required' if credentials.size < 2

        if credentials[0].blank?
          return authentication_response(return_value: false, failure: :invalid_login, &block)
        end

        # 省略

        user = sorcery_adapter.find_by_credentials(credentials)

        unless user
          return authentication_response(failure: :invalid_login, &block)
        end

        set_encryption_attributes

        if user.respond_to?(:active_for_authentication?) && !user.active_for_authentication?
          return authentication_response(user: user, failure: :inactive, &block)
        end

        @sorcery_config.before_authenticate.each do |callback|
          success, reason = user.send(callback)

          unless success
            return authentication_response(user: user, failure: reason, &block)
          end
        end

        unless user.valid_password?(credentials[1])
          return authentication_response(user: user, failure: :invalid_password, &block)
        end

        authentication_response(user: user, return_value: user, &block)
      end

      # 省略

      protected

      def authentication_response(options = {})
        yield(options[:user], options[:failure]) if block_given?

        options[:return_value]
      end

      # 省略

2-1. 引数チェック

lib/sorcery/model.rb
        raise ArgumentError, 'at least 2 arguments required' if credentials.size < 2

まず、credentials.sizeが 2 つ未満の場合はArgumentErrorを出しています。今回はparams[:email], params[:password]の 2 つを引数で渡しているので大丈夫そうです。
credentialsの中身は今、['test@example.com', 'password']ですね。

2-2. 空白チェック

lib/sorcery/model.rb
        if credentials[0].blank?
          return authentication_response(return_value: false, failure: :invalid_login, &block)
        end

第 1 引数(今回はparams[:email])が空白の場合は認証失敗となります。
authentication_responseメソッドは以下のように定義されております。
:invalid_login という失敗情報を引数で引き渡し、 yield が Controller 内のブロック( 3 認証失敗時の処理以降 )を実行します。falseを戻り値として返します。

lib/sorcery/model.rb
      def authentication_response(options = {})
        yield(options[:user], options[:failure]) if block_given?

        options[:return_value]
      end

2-3. ユーザー検索

lib/sorcery/model.rb
        user = sorcery_adapter.find_by_credentials(credentials)

データベースから認証情報でユーザーを検索します

sorcery_adapterは Sorcery ライブラリがデータベースや ORM とやり取りするための抽象化レイヤーとして存在するもので、異なる ORM(ActiveRecord、DataMapper、MongoMapper など)に対して統一されたインターフェースを提供します。
ActiveRecord を使用している場合は、module Sorceryで以下のように定義されており、sorcery_adapterSorcery::Adapters::ActiveRecordAdapter.from(self)の戻り値となります。

lib/sorcery.rb
require 'sorcery/version'

module Sorcery
  require 'sorcery/model'

  # 省略

  require 'sorcery/adapters/base_adapter'

  if defined?(ActiveRecord::Base)
    require 'sorcery/adapters/active_record_adapter'
    ActiveRecord::Base.extend Sorcery::Model

    # 省略

    ActiveRecord::Base.send :define_singleton_method, :sorcery_adapter do # ← ActiveRecord::Baseクラスにsorcery_adapterというクラスメソッドを定義
      Sorcery::Adapters::ActiveRecordAdapter.from(self)
    end

  # 省略

Sorcery::Adapters::ActiveRecordAdapter.from(self)は以下のように定義されており、
引数klass@klassにし、selfを返します。

lib/sorcery/adapters/base_adapter.rb
module Sorcery
  module Adapters
    class BaseAdapter
      # 省略

      def self.from(klass)
        @klass = klass
        self
      end

さて、ここでsorcery_adapter.find_by_credentialsの部分に目を戻します。

lib/sorcery/model.rb
module Sorcery
  # 省略
  module Model
    def authenticates_with_sorcery!
      @sorcery_config = Config.new

      extend ClassMethods

      # 省略

    module ClassMethods

      # 省略

      def authenticate(*credentials, &block)

        # 省略

        user = sorcery_adapter.find_by_credentials(credentials)

よくよくこのauthenticateメソッドの呼び出し元を見直すと、loginメソッドの部分で、user_classから呼び出されたものでしたので、user_classselfです。user_classUserクラスであることは前述の通りですので、結局のところ、sorcery_adapterの戻り値はUserクラスのこととなります。
また、途中で代入していた@klassにもUserクラスが入っていることを覚えておいてください。

lib/sorcery/controller.rb
      def login(*credentials)
        @current_user = nil

        user_class.authenticate(*credentials) do |user, failure_reason|

sorcery_adapter.find_by_credentials(credentials)は展開するとSorcery::Adapters::ActiveRecordAdapter.from(User).find_by_credentials(credentials)であり、以下のように定義されています。

lib/sorcery/adapters/active_record_adapter.rb
        def find_by_credentials(credentials)
          relation = nil

          @klass.sorcery_config.username_attribute_names.each do |attribute|
            if @klass.sorcery_config.downcase_username_before_authenticating
              condition = @klass.arel_table[attribute].lower.eq(@klass.arel_table.lower(credentials[0]))
            else
              condition = @klass.arel_table[attribute].eq(credentials[0]) # 「users.email = 'test@example.com'」というSQL文の一部を作成
            end

            relation = if relation.nil? # 「users.email = 'test@example.com'」
                         condition
                       else
                         relation.or(condition)
                       end
          end

        @klass.where(relation).first # SQL発行
        end

@klass.sorcery_config.username_attribute_namesは配列[:email]が格納されており、最終的にklass.where(relation).firstで発行される SQL 文は以下になります。

SELECT * FROM users
WHERE (users.email = 'test@example.com')
LIMIT 1

これで当該の User インスタンスを取得することができました。見つからない場合は :invalid_login で失敗の処理をします。

2-4. 暗号化設定の適用

lib/sorcery/model.rb
        set_encryption_attributes

パスワード暗号化プロバイダーの設定を適用、BCrypt のストレッチ数やペッパーなどを設定します。今回はなにもしていないのでスキップされます。

2-5. アクティブ状態チェック

lib/sorcery/model.rb
        if user.respond_to?(:active_for_authentication?) && !user.active_for_authentication?
          return authentication_response(user: user, failure: :inactive, &block)
        end

ユーザーがアクティブかどうかをチェックします。active_for_authentication?は我々 sorcery の利用者が User モデルに以下のような定義をすることで、凍結されたアカウントからのログインを防いだりすることができるオプション機能です。

app/models/user.rb
class User < ActiveRecord::Base
  authenticates_with_sorcery!

  # カスタムで定義
  def active_for_authentication?
    # 例:アカウントが有効化されているかチェック
    activation_state == 'active'
  end
end

今回はactive_for_authentication? メソッド自体がないのでスキップされます。

2-6. 認証前コールバック実行

lib/sorcery/model.rb
        @sorcery_config.before_authenticate.each do |callback|
          success, reason = user.send(callback)

          unless success
            return authentication_response(user: user, failure: reason, &block)
          end
        end

設定された認証前フックを実行します。今回は初期状態のままなにも定義していないのでスキップされます。

lib/sorcery/model/config.rb
          :@before_authenticate                  => [],

2-7. パスワード検証

lib/sorcery/model.rb
        unless user.valid_password?(credentials[1])
          return authentication_response(user: user, failure: :invalid_password, &block)
        end

credentials[1]を引数にとり(今回はcredentials[1] = 'password'ですね)、valid_password?メソッドでパスワードを検証します。
valid_password?メソッドは同じファイルの下の方に定義してあります。ハッシュ化されたパスワードと入力パスワードを比較するメソッドです。
不一致falseの場合は :invalid_password エラーを戻り値でreturnします。

lib/sorcery/model.rb
    module InstanceMethods
      # Returns the class instance variable for configuration, when called by an instance.
      def sorcery_config
        self.class.sorcery_config
      end

      # 省略

      def valid_password?(pass)
        crypted = send(sorcery_config.crypted_password_attribute_name) # usersテーブルのcrypted_passwordカラムのレコードを取得
        return crypted == pass if sorcery_config.encryption_provider.nil? # 初期化時にencryption_providerにはCryptoProviders::BCryptが入っているのでスキップ

        # Ensure encryption provider is using configured values
        self.class.set_encryption_attributes # オプションの設定、通常はスキップ

        salt = send(sorcery_config.salt_attribute_name) unless sorcery_config.salt_attribute_name.nil? # saltカラムに保存されたランダム文字列を取得

        sorcery_config.encryption_provider.matches?(crypted, pass, salt)
      end

crypted_password_attribute_nameはデフォルトでは以下のように定義してあり、:crypted_passwordが入っています。
今回のsend(sorcery_config.crypted_password_attribute_name)user.crypted_passwordであり、テーブルのcrypted_passwordカラムに記録されたハッシュ値を取得し、変数cryptedに代入しています。
self.class.set_encryption_attributesは、オプションの設定で、ハッシュ化の回数やパスワードとソルトを結合する際のパターン指定、固有秘密鍵の追加設定があれば適応するメソッドです。
その後、salt = send(sorcery_config.salt_attribute_name)でテーブルの salt カラムに保存されたランダム文字列を取得します。
最後のsorcery_config.encryption_provider.matches?(crypted, pass, salt)ですが、オプション指定していない限りはCryptoProviders::BCryptというのが設定されています。では、CryptoProviders::BCrypt.matches?(crypted, pass, salt)を見ていきます。

lib/sorcery/model/config.rb
          :@crypted_password_attribute_name      => :crypted_password,

          ...

          :@encryption_provider                  => CryptoProviders::BCrypt,

          ...

          :@salt_attribute_name                  => :salt,

CryptoProviders::BCrypt.matches?(crypted, pass, salt)は以下のようになっています。
matches?メソッド内部では、まず、new_from_hash(hash)というプライベートメソッドで、::BCrypt::Password.new(hash)をし、戻り値hashjoin_tokens(tokens)の比較結果をtruefalseで返却しています。

lib/sorcery/crypto_providers/bcrypt.rb
require 'bcrypt'

module Sorcery
  module CryptoProviders
    class BCrypt
      class << self

        # 省略

        def matches?(hash, *tokens)
          hash = new_from_hash(hash)
          return false if hash.nil? || hash == {}

          hash == join_tokens(tokens)
        end

        # 省略

        private

        def join_tokens(tokens)
          tokens.flatten.join.concat(pepper.to_s) # make sure to add pepper in case tokens have only one element
        end

        def new_from_hash(hash)
          ::BCrypt::Password.new(hash)
        rescue ::BCrypt::Errors::InvalidHash
          nil
        end

BCryptによるパスワード検証の仕組み

この部分は認証の最も重要な部分です。BCryptがどのようにパスワードを検証するのか詳しく見てみましょう。

検証の概要
  1. 入力されたパスワード(平文)を取得
  2. データベースに保存されたハッシュ値を取得
  3. BCryptの特殊な比較方法で一致するかチェック

詳細な動作をデバッグで確認してみます:

From: /Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/crypto_providers/bcrypt.rb @ line 68 :

    63:         # Does the hash match the tokens? Uses the same tokens that were used to encrypt.
    64:         def matches?(hash, *tokens)
    65:           hash = new_from_hash(hash)
    66:           return false if hash.nil? || hash == {}
    67:
 => 68:           binding.irb
    69:
    70:           hash == join_tokens(tokens)
    71:         end
    72:
    73:         # This method is used as a flag to tell Sorcery to "resave" the password

irb(Sorcery::CryptoProviders::BCrypt):001> hash
=> "$2a$10$Dcx1ZvCw9uATkO8rMfzZ6es37nRyVYQDbGUJ3bWop/SWcTmBX6nPW"
irb(Sorcery::CryptoProviders::BCrypt):002> tokens
=> ["password", "o8zyYVstS7wTm7-DgTDs"]
irb(Sorcery::CryptoProviders::BCrypt):003> join_tokens(tokens)
=> "passwordo8zyYVstS7wTm7-DgTDs"
irb(Sorcery::CryptoProviders::BCrypt):004> hash == join_tokens(tokens)
=> true

2-7-1. hash の正体

hash => "$2a$10$Dcx1ZvCw9uATkO8rMfzZ6es37nRyVYQDbGUJ3bWop/SWcTmBX6nPW"

これは ::BCrypt::Password.new(hash) で作成された BCrypt::Password オブジェクト です(文字列ではありません)。

2-7-2. tokens には引数で受け取ったパスワードとソルト

tokens => ["password", "o8zyYVstS7wTm7-DgTDs"]

2-7-3. join_tokens(tokens)は単純にパスワードとソルト文字列を結合

join_tokens(tokens) => "passwordo8zyYVstS7wTm7-DgTDs"

2-7-4. BCrypt::Password オブジェクト("$2a$10$Dcx1ZvCw9uATkO8rMfzZ6es37nRyVYQDbGUJ3bWop/SWcTmBX6nPW")と、結合した"passwordo8zyYVstS7wTm7-DgTDs"を比較

irb(Sorcery::CryptoProviders::BCrypt):004> hash == join_tokens(tokens)
=> true

あれ、よくわからないですね。
"$2a$10$Dcx1ZvCw9uATkO8rMfzZ6es37nRyVYQDbGUJ3bWop/SWcTmBX6nPW""passwordo8zyYVstS7wTm7-DgTDs"を比較した場合、明らかにfalseのはずですが、hash == join_tokens(tokens)の戻り値はtrueになっています。これは文字列を比較しているのではなく、BCrypt::Password オブジェクトの==比較演算子がカスタマイズされており、実際には以下のような==比較演算子が呼ばれています。

BCrypt ライブラリの == 演算子の実装では、 以下のような処理で「平文を自動的にハッシュ化して比較する」特殊な実装になっているため、文字列の直接比較ではなく、暗号学的に正しいパスワード検証が行われています。

lib/bcrypt/password.rb
    def ==(secret)
      hash = BCrypt::Engine.hash_secret(secret, @salt) # 入力された平文 secret と保存されているソルト @salt を使って新しいハッシュを生成

      return false if hash.strip.empty? || strip.empty? || hash.bytesize != bytesize # ハッシュが空でないか、長さが一致するかをチェック

      # Constant time comparison so they can't tell the length.
      res = 0
      bytesize.times { |i| res |= getbyte(i) ^ hash.getbyte(i) }
      res == 0 # 定数時間比較という方法でセキュアな比較を実施
    end

通常の文字列比較の場合、最初に異なる文字が見つかった時点で処理終了されますが、与える文字列によって処理の演算回数が変わるので、その実行時間を計測しながら何度も試行することで正しい文字列が推測できてしまう可能性(タイミング攻撃の脆弱性)があり、この定数時間比較という方法は、入力の値にかかわらず定数時間で処理できるようにされています。

また、BCrypt::Password オブジェクトの"$2a$10$Dcx1ZvCw9uATkO8rMfzZ6es37nRyVYQDbGUJ3bWop/SWcTmBX6nPW"は 「BCrypt で暗号化されたパスワードハッシュ」そのものです。
構造は以下のようになっており、それぞれ部分ごとに意味があります。

$2a$10$Dcx1ZvCw9uATkO8rMfzZ6es37nRyVYQDbGUJ3bWop/SWcTmBX6nPW
│││ │  │                      │
│││ │  └─ ソルト(22文字)      └─ 実際のハッシュ値(31文字)
│││ └─ コスト(10 = 2^10 = 1024回の処理)
││└─ マイナーバージョン(a)
│└─ メジャーバージョン(2)
└─ 識別子($)

各部分の意味

部分 意味
$2a$ バージョン情報 BCryptのバージョン(2a = 現在の標準)
10 コスト 2^10 = 1024回のハッシュ処理を実行(コストが 1 上がるごとに処理時間が 2 倍になる)
Dcx1ZvCw9uATkO8rMfzZ6e ソルト ランダムに生成された22文字の文字列
s37nRyVYQDbGUJ3bWop/SWcTmBX6nPW ハッシュ値 実際に暗号化されたパスワード(31文字)

以上から、CryptoProviders::BCrypt.matches?(crypted, pass, salt)の戻り値truefalseによってパスワードが一致しているか不一致なのかの判定ができることがわかりました。

user.valid_password?(credentials[1])の戻り値はパスワードが一致する場合はtrueとなり、unless内には入らずに次の処理にいきます。

2-8. 認証成功

lib/sorcery/model.rb
        authentication_response(user: user, return_value: user, &block)

全てのチェックを通過した場合の成功処理です。
3 認証失敗時の処理 以降のブロックを処理したあと、userオブジェクトを返します。

3. 認証失敗時の処理

lib/sorcery/controller.rb
          if failure_reason
            after_failed_login!(credentials) # 設定されたフック(コールバック)を順次実行

            yield(user, failure_reason) if block_given? # コントローラーで login メソッドがブロック付きで呼ばれた場合、失敗情報を返す。今回のSessionsControllerではブロックを渡していないので、スキップ。

            return # 処理終了
          end

失敗理由がある場合、失敗後のフックを実行します。呼び出し元(SessionsController)からブロックが渡されていれば、失敗情報を yield して実行します。
以上が終われば、早期 return で処理終了します。

4. 認証成功時のセッション処理

lib/sorcery/controller.rb
          old_session = session.dup.to_hash # session.dup.to_hash => {"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}
          reset_sorcery_session
          old_session.each_pair do |k, v|
            session[k.to_sym] = v
          end
          form_authenticity_token

セッション固定攻撃対策とCSRF対策を行う部分です:

  1. 既存のセッションデータを一時保存
  2. セッションをリセット(新しいセッション ID を生成)
  3. 必要なデータを新しいセッションに復元
  4. CSRF トークンを生成

ここも大事な大事な部分なので、少しデバックしてみます。

4-1. 既存のセッションデータをold_sessionに一時保存

From: /Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb @ line 52 :

    47:             # rubocop:disable Lint/NonLocalExitFromIterator
    48:             return
    49:             # rubocop:enable Lint/NonLocalExitFromIterator
    50:           end
    51:
=> 52:           binding.irb
    53:
    54:           old_session = session.dup.to_hash
    55:
    56:           binding.irb
    57:           reset_sorcery_session

irb(#<SessionsController:0x000000...):001> session.dup.to_hash
=> {"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}
irb(#<SessionsController:0x000000...):002> old_session
=> nil

old_session に一時保存します。

From: /Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb @ line 56 :

    51:
    52:           binding.irb
    53:
    54:           old_session = session.dup.to_hash
    55:
 => 56:           binding.irb
    57:           reset_sorcery_session
    58:
    59:           binding.irb
    60:           old_session.each_pair do |k, v|
    61:             session[k.to_sym] = v

irb:rdbg(#<SessionsController:0x000000...):004> session.dup.to_hash
{"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}
irb:rdbg(#<SessionsController:0x000000...):005> old_session
{"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}

4-2. セッションをリセット(新しいセッション ID を生成)

From: /Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb @ line 59 :

    54:           old_session = session.dup.to_hash
    55:
    56:           binding.irb
    57:           reset_sorcery_session
    58:
 => 59:           binding.irb
    60:           old_session.each_pair do |k, v|
    61:             session[k.to_sym] = v
    62:           end
    63:
    64:           binding.irb

irb:rdbg(#<SessionsController:0x000000...):007> session.dup.to_hash
{"session_id"=>"158cd00717dc68e6e00aae5f8227b849"}
irb:rdbg(#<SessionsController:0x000000...):008> old_session
{"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}

reset_sorcery_sessionメソッドによって、"session_id"が新たなものが発行され、"_csrf_token"もなくなりました。
reset_sorcery_sessionメソッド内では rails のreset_sessionメソッドが呼ばれています。これにより新しいセッション作成がなされます。
https://railsguides.jp/security.html#%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3%E5%9B%BA%E5%AE%9A%E6%94%BB%E6%92%83-%E5%AF%BE%E5%BF%9C%E7%AD%96

4-3. 必要なデータを新しいセッションに復元、CSRF トークンを生成

From: /Users/your_name/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sorcery-0.17.0/lib/sorcery/controller.rb @ line 67 :

    62:           end
    63:
    64:           binding.irb
    65:           form_authenticity_token
    66:
 => 67:           binding.irb
    68:
    69:           auto_login(user, credentials[2])
    70:           after_login!(user, credentials)
    71:
    72:           block_given? ? yield(current_user, nil) : current_user

irb:rdbg(#<SessionsController:0x000000...):013> session.dup.to_hash
{"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}
irb:rdbg(#<SessionsController:0x000000...):014> old_session
{"session_id"=>"87483c2fd1696a1ae014ce67487fd9de", "_csrf_token"=>"h8Y4H6dtRe50w87SJiuUH_VF4ENL5YJWc48bKzxuRn4"}

せっかく、新たなセッションを作成したところですが、ログイン前のユーザーの状態を保つため、必要なデータを復元しておきます。このとき古いsession_idも復元します。
さらに、form_authenticity_tokenメソッドで新しい CSRF トークンで上書きします。このメソッドも rails が提供する CSRF の対策のメソッドで、有効な認証トークンを生成します。
https://railsguides.jp/action_controller_advanced_topics.html#%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E3%81%AE%E8%AA%8D%E8%A8%BC%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3

5. ログイン完了処理

lib/sorcery/controller.rb
          auto_login(user, credentials[2])
          after_login!(user, credentials)

auto_login でユーザーをセッションに保存します。さらに、@current_useruserオブジェクトを入れます。ここで@current_userが誕生するんですね。

lib/sorcery/controller.rb
      def auto_login(user, _should_remember = false)
        session[:user_id] = user.id.to_s
        @current_user = user
      end

ログイン後の設定されたフック(コールバック)を順次実行します(今回はなにもないのでスキップされます)。

6. 戻り値

lib/sorcery/controller.rb
          block_given? ? yield(current_user, nil) : current_user

ブロックが与えられている場合:成功情報を yield し実行した結果を返します。
ブロックがない場合:current_user を直接返します。(今回はブロックを渡していないので、こちらです)

まとめ

Sorcery のloginメソッドは、一見シンプルに見えますが、内部では非常に多くの処理が実行されていることがわかりました。

loginメソッドの処理フロー

  1. 初期化: @current_userをリセット
  2. 認証処理: User.authenticateメソッドで以下を実行
    • 引数の妥当性チェック(最低 2 つの引数が必要)
    • メールアドレスの空白チェック
    • データベースからユーザー検索(SQL クエリ実行)
    • 暗号化設定の適用
    • アクティブ状態チェック(オプション)
    • 認証前コールバック実行(オプション)
    • パスワード検証(BCrypt による暗号学的比較)
  3. 認証失敗時: フックを実行し早期 return
  4. 認証成功時: セッション固定攻撃対策の処理
    • 既存セッションの一時保存
    • セッションリセット(新しいセッション ID 生成)
    • 必要データの復元
    • CSRF トークン生成
  5. ログイン完了: session[:user_id]設定と@current_userへの代入

ポイント

「ログインする」の裏側で起こっていること

  1. 入力値の検証:メールアドレスとパスワードの形式チェック
  2. ユーザー検索:データベースからメールアドレスでユーザーを探す
  3. パスワード照合:BCryptによる安全な暗号化比較
  4. セッション管理:ログイン状態を記録し、セキュリティ対策を実施
  5. 認証完了:ユーザー情報をメモリに保存

セキュリティ対策

  • BCrypt によるパスワードハッシュ化: ソルト付きで暗号学的に安全
  • ソルト追加:同じパスワードでも異なるハッシュ値になる
  • 定数時間比較: タイミング攻撃を防ぐ
  • セッション固定攻撃対策: ログイン時にセッション ID を再生成
  • CSRF 対策: 新しい認証トークンを生成

パスワード検証の仕組み

BCrypt::Passwordオブジェクトの==演算子は特別で、平文パスワードを自動的にハッシュ化して比較します。これにより、保存されたハッシュ値と入力されたパスワードを安全に比較できます。

設計の巧妙さ

  • 抽象化: sorcery_adapterにより異なる ORM に対応
  • フック機能: 認証前後に任意の処理を挿入可能
  • 設定の柔軟性: 初期化ファイルで細かくカスタマイズ可能

学んだこと

「ログインしてくれます」という一言で片付けられがちなloginメソッドですが、実際には:

  • BCrypt や rails のメソッドが連携して動作
  • セキュリティを最優先に設計
  • 拡張性カスタマイズ性を重視
  • rails の仕組み(Engine、initializer 等)を巧みに活用

このような詳細を理解することで、認証システムの重要性と複雑さを改めて認識できました。
認証は「簡単そうに見えて実は奥が深い」分野です。自作する場合は、これらすべての要素を考慮する必要があることを認識すると同時に、ありがたく我々は簡単にこれらの実装を使わせていただいていることに感謝です。


5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?