「ログインする」ってなに?
新入社員に 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 モデル、マイグレーションファイルができ、テーブルに反映されます
class User < ApplicationRecord
authenticates_with_sorcery!
end
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
を以下に編集します。
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. ログイン画面を作成
ログインフォームを作成します。
<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>
要素内を以下のように追記しておきます。
<body>
<% flash.each do |type, message| %>
<div class="flash <%= type %>"><%= message %></div>
<% end %>
<%= yield %>
</body>
3. ルーティングを追加
/login
で一連のログインの動きができるようにしておきます。
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 メソッドがなにをやっているのかを読み解いていきます。
# 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. 初期化
@current_user = nil
現在のユーザー情報をリセットします。
rails を触っているとよくでてくる@current_user
ですね。まずはここでは、@current_user
にnil
を突っ込んでいます。
2. 認証処理
user_class.authenticate(*credentials) do |user, failure_reason|
user_class
のauthenticate
メソッドを呼び出しています。ブロックを渡して認証結果を処理しているようです。
user_class
というわからない変数がでてきました。これは、このファイルの下の方で以下のように定義してあります。
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_class
やConfig.user_class.to_s.constantize
を見ていきましょう。その前に、このあたりを理解するためには、このlib/sorcery/controller.rb
のはじめのほう L1~19 を少し読んでおく必要があります。
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
が実行されています)。
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 起動時の流れ
- rails アプリケーション起動開始
- Gemfile から gem を読み込み
- 各 gem の Engine クラスが読み込まれる
- ※ ここで Sorcery::Engine も読み込まれる
- rails が全ての initializer を収集
- 優先順位に従って initializer を実行 ← ※ ここで実行!
- アプリケーションコードの読み込み
- サーバー起動完了
ここまでわかったことをもう一度まとめると、①rails アプリ起動時にinitializer 'extend Controller with sorcery' do
が呼ばれる。②include Sorcery::Controller
が実行される。③Sorcery::Controller.included
が実行される。ということがわかりました。
では続き、self.included
メソッド内の、Config.update!
メソッドとConfig.configure!
メソッドを見ていきましょう。
まずは、Sorcery::Controller::Config
の全体像を把握します。
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"
を代入しています。
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
に代入されます。
def configure(&blk)
@configure_blk = blk # ← 「config.user_class = "User"」含む、ブロックの中身を変数に代入
end
そして、self.included
メソッド内のConfig.configure!
メソッドによって、実際に"User"
が:user_class
属性に定義されます。ここまで読んで、rails の起動時にuser_class
に文字列"User"
が格納されることがわかりました。
def configure!
@configure_blk.call(self) if @configure_blk # ← 実際に config.user_class = "User" を実行
end
以上から、やっとuser_class
メソッドがわかってきます。
現在@user_class
はnil
ですので、Config.user_class.to_s.constantize
が入ります。
constantize
は文字列やシンボルを Ruby の定数(クラスやモジュール)に変換するメソッドで、今回は"User"
ですので戻り値はUser
クラス(User
モデル)になります。
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
メソッドを見ていきます。
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]
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. 引数チェック
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. 空白チェック
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
を戻り値として返します。
def authentication_response(options = {})
yield(options[:user], options[:failure]) if block_given?
options[:return_value]
end
2-3. ユーザー検索
user = sorcery_adapter.find_by_credentials(credentials)
データベースから認証情報でユーザーを検索します
sorcery_adapter
は Sorcery ライブラリがデータベースや ORM とやり取りするための抽象化レイヤーとして存在するもので、異なる ORM(ActiveRecord、DataMapper、MongoMapper など)に対して統一されたインターフェースを提供します。
ActiveRecord を使用している場合は、module Sorcery
で以下のように定義されており、sorcery_adapter
はSorcery::Adapters::ActiveRecordAdapter.from(self)
の戻り値となります。
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
を返します。
module Sorcery
module Adapters
class BaseAdapter
# 省略
def self.from(klass)
@klass = klass
self
end
さて、ここでsorcery_adapter.find_by_credentials
の部分に目を戻します。
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_class
がself
です。user_class
がUser
クラスであることは前述の通りですので、結局のところ、sorcery_adapter
の戻り値はUser
クラスのこととなります。
また、途中で代入していた@klass
にもUser
クラスが入っていることを覚えておいてください。
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)
であり、以下のように定義されています。
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. 暗号化設定の適用
set_encryption_attributes
パスワード暗号化プロバイダーの設定を適用、BCrypt のストレッチ数やペッパーなどを設定します。今回はなにもしていないのでスキップされます。
2-5. アクティブ状態チェック
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 モデルに以下のような定義をすることで、凍結されたアカウントからのログインを防いだりすることができるオプション機能です。
class User < ActiveRecord::Base
authenticates_with_sorcery!
# カスタムで定義
def active_for_authentication?
# 例:アカウントが有効化されているかチェック
activation_state == 'active'
end
end
今回はactive_for_authentication?
メソッド自体がないのでスキップされます。
2-6. 認証前コールバック実行
@sorcery_config.before_authenticate.each do |callback|
success, reason = user.send(callback)
unless success
return authentication_response(user: user, failure: reason, &block)
end
end
設定された認証前フックを実行します。今回は初期状態のままなにも定義していないのでスキップされます。
:@before_authenticate => [],
2-7. パスワード検証
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
します。
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)
を見ていきます。
:@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)
をし、戻り値hash
とjoin_tokens(tokens)
の比較結果をtrue
、false
で返却しています。
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がどのようにパスワードを検証するのか詳しく見てみましょう。
検証の概要
- 入力されたパスワード(平文)を取得
- データベースに保存されたハッシュ値を取得
- 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 ライブラリの ==
演算子の実装では、 以下のような処理で「平文を自動的にハッシュ化して比較する」特殊な実装になっているため、文字列の直接比較ではなく、暗号学的に正しいパスワード検証が行われています。
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)
の戻り値true
、false
によってパスワードが一致しているか不一致なのかの判定ができることがわかりました。
user.valid_password?(credentials[1])
の戻り値はパスワードが一致する場合はtrue
となり、unless
内には入らずに次の処理にいきます。
2-8. 認証成功
authentication_response(user: user, return_value: user, &block)
全てのチェックを通過した場合の成功処理です。
3 認証失敗時の処理 以降のブロックを処理したあと、user
オブジェクトを返します。
3. 認証失敗時の処理
if failure_reason
after_failed_login!(credentials) # 設定されたフック(コールバック)を順次実行
yield(user, failure_reason) if block_given? # コントローラーで login メソッドがブロック付きで呼ばれた場合、失敗情報を返す。今回のSessionsControllerではブロックを渡していないので、スキップ。
return # 処理終了
end
失敗理由がある場合、失敗後のフックを実行します。呼び出し元(SessionsController)からブロックが渡されていれば、失敗情報を yield して実行します。
以上が終われば、早期 return で処理終了します。
4. 認証成功時のセッション処理
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対策を行う部分です:
- 既存のセッションデータを一時保存
- セッションをリセット(新しいセッション ID を生成)
- 必要なデータを新しいセッションに復元
- 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. ログイン完了処理
auto_login(user, credentials[2])
after_login!(user, credentials)
auto_login
でユーザーをセッションに保存します。さらに、@current_user
にuser
オブジェクトを入れます。ここで@current_user
が誕生するんですね。
def auto_login(user, _should_remember = false)
session[:user_id] = user.id.to_s
@current_user = user
end
ログイン後の設定されたフック(コールバック)を順次実行します(今回はなにもないのでスキップされます)。
6. 戻り値
block_given? ? yield(current_user, nil) : current_user
ブロックが与えられている場合:成功情報を yield
し実行した結果を返します。
ブロックがない場合:current_user
を直接返します。(今回はブロックを渡していないので、こちらです)
まとめ
Sorcery のlogin
メソッドは、一見シンプルに見えますが、内部では非常に多くの処理が実行されていることがわかりました。
login
メソッドの処理フロー
-
初期化:
@current_user
をリセット -
認証処理:
User.authenticate
メソッドで以下を実行- 引数の妥当性チェック(最低 2 つの引数が必要)
- メールアドレスの空白チェック
- データベースからユーザー検索(SQL クエリ実行)
- 暗号化設定の適用
- アクティブ状態チェック(オプション)
- 認証前コールバック実行(オプション)
- パスワード検証(BCrypt による暗号学的比較)
- 認証失敗時: フックを実行し早期 return
-
認証成功時: セッション固定攻撃対策の処理
- 既存セッションの一時保存
- セッションリセット(新しいセッション ID 生成)
- 必要データの復元
- CSRF トークン生成
-
ログイン完了:
session[:user_id]
設定と@current_user
への代入
ポイント
「ログインする」の裏側で起こっていること
- 入力値の検証:メールアドレスとパスワードの形式チェック
- ユーザー検索:データベースからメールアドレスでユーザーを探す
- パスワード照合:BCryptによる安全な暗号化比較
- セッション管理:ログイン状態を記録し、セキュリティ対策を実施
- 認証完了:ユーザー情報をメモリに保存
セキュリティ対策
- BCrypt によるパスワードハッシュ化: ソルト付きで暗号学的に安全
- ソルト追加:同じパスワードでも異なるハッシュ値になる
- 定数時間比較: タイミング攻撃を防ぐ
- セッション固定攻撃対策: ログイン時にセッション ID を再生成
- CSRF 対策: 新しい認証トークンを生成
パスワード検証の仕組み
BCrypt::Password
オブジェクトの==
演算子は特別で、平文パスワードを自動的にハッシュ化して比較します。これにより、保存されたハッシュ値と入力されたパスワードを安全に比較できます。
設計の巧妙さ
-
抽象化:
sorcery_adapter
により異なる ORM に対応 - フック機能: 認証前後に任意の処理を挿入可能
- 設定の柔軟性: 初期化ファイルで細かくカスタマイズ可能
学んだこと
「ログインしてくれます」という一言で片付けられがちなlogin
メソッドですが、実際には:
- BCrypt や rails のメソッドが連携して動作
- セキュリティを最優先に設計
- 拡張性とカスタマイズ性を重視
- rails の仕組み(Engine、initializer 等)を巧みに活用
このような詳細を理解することで、認証システムの重要性と複雑さを改めて認識できました。
認証は「簡単そうに見えて実は奥が深い」分野です。自作する場合は、これらすべての要素を考慮する必要があることを認識すると同時に、ありがたく我々は簡単にこれらの実装を使わせていただいていることに感謝です。
もっと他の記事も読んでみたい方
当社に興味がある方はこちら👀