graphql-rubyで認可制御を行うready?メソッドとauthorized?メソッド

こんにちは!kossyです!




さて、今回はgraphql-rubyで認可制御を行うready?メソッドとauthorized?メソッドについて、

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




環境

Ruby 2.6.9
Rails 6.0.4
MacOS BigSur
graphql-ruby 1.13.0



graphql-rubyでの認可制御

graphql-rubyのドキュメントを見ると、Mutationの認可制御メソッドとして、ready?メソッドとauthorized?メソッドが使えると記載があります。

graphql-ruby.org

それぞれどのような違いがあるのでしょうか。ソースコードを見てみましょう。


ready?メソッドのソースコードを見る

まずはソースコードを読んでみます。

github.com

def ready?(**args)
  true
end

有無を言わさず true を返すメソッドになっていました。ちょっと意図がよくわからないので、コメントアウト部分を読んでみましょう。

Called before arguments are prepared.
Implement this hook to make checks before doing any work.

argumentsが準備される前に呼び出されます。
このフックを実装して、resolverが実行される前にチェックを行います。

If it returns a lazy object (like a promise), it will be synced by GraphQL
(but the resulting value won't be used).

遅延実行されるオブジェクト(Promiseのようなオブジェクトです)を返す場合、GraphQLによって同期されます。
(ただし、結果の値は使用されません)。

出典: graphql-ruby/lib/graphql/schema/resolver.rb at master · rmosolgo/graphql-ruby · GitHub

呼び出しのタイミングは、引数が準備される前とのことでした。


authorized?

こちらもソースコードを見てみます。

def authorized?(**inputs)
  self.class.arguments(context).each_value do |argument|
    arg_keyword = argument.keyword
    if inputs.key?(arg_keyword) && !(arg_value = inputs[arg_keyword]).nil? && (arg_value != argument.default_value)
      arg_auth, err = argument.authorized?(self, arg_value, context)
      if !arg_auth
        return arg_auth, err
      else
        true
      end
    else
      true
    end
  end
end

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

Called after arguments are loaded, but before resolving.

引数がロードされた後、resolverの前に呼び出されます。

出典: graphql-ruby/lib/graphql/schema/resolver.rb at master · rmosolgo/graphql-ruby · GitHub

とのことでした。よしなにオーバーライドしてもOKなようです。

ready?とauthorized?のユースケース

ready?は引数がロードされる前に実行されるとのことなので、引数の値抜きで権限チェックを行いたい時に使うのがいいと思います。

def ready?
  if context[:current_user].admin?
    true
  else
     raise GraphQL::ExecutionError, "Only admins can run this mutation"
  end
end

逆にauthorized?メソッドは引数がロードされた後に実行されるとのことなので、引数の値込みで権限チェックを行いたい時に使うのがいいと思います。

例えば、管理者権限でも更新できない値があった場合に例外を発生させたりするパターンが考えられます。(履歴データとか)

def authorized?(**inputs)
  if context[:current_user].admin? && context[:current_user].can_edit_all?(inputs)
    true
  else
    raise GraphQL::ExecutionError, "Inculde Cannnot Edit Value"
  end
end


勉強になりました。

パスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみた

こんにちは!kossyです!




今回はパスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみたので、

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




環境
Ruby 2.6.9
Rails 6.0.4
MacOS BigSur



createアクション

まずはcreateアクションのソースコードから読んでみます。

github.com

  # POST /resource/password
  def create
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    else
      respond_with(resource)
    end
  end

resource_classは User のように、deviseを使って認証を行うモデルのクラスが返ります。

send_reset_password_instructionsはどんな処理でしょうか。

send_reset_password_instructions

# Attempt to find a user by its email. If a record is found, send new
# password instructions to it. If user is not found, returns a new user
# with an email not found error.
# Attributes must contain the user's email

# ↓Google翻訳先生に訳してもらいました。↓
# メールでユーザーを見つけようとします。 
# レコードが見つかった場合は、そのレコードに新しいパスワードの指示を送信します。 
# ユーザーが見つからない場合は、メールが見つかりませんというエラーで新しいユーザーを返します。
# 属性にはユーザーのメールアドレスが含まれている必要があります

def send_reset_password_instructions(attributes = {})
  recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
  recoverable.send_reset_password_instructions if recoverable.persisted?
  recoverable
end

コメントアウト部分で挙動について丁寧に説明されているので、内部で呼んでいるメソッドについては読まなくてもいいかなと思いつつ、

念のため目を通してみます。

find_or_initialize_with_errors

github.com

# Find or initialize a record with group of attributes based on a list of required attributes.
# 必要な属性のリストに基づいて、属性のグループを使用してレコードを検索または初期化します。

def find_or_initialize_with_errors(required_attributes, attributes, error = :invalid) #:nodoc:
  attributes.try(:permit!)
  attributes = attributes.to_h.with_indifferent_access
                          .slice(*required_attributes)
                          .delete_if { |key, value| value.blank? }

  if attributes.size == required_attributes.size
    record = find_first_by_auth_conditions(attributes) and return record
  end

  new(devise_parameter_filter.filter(attributes)).tap do |record|
    required_attributes.each do |key|
      record.errors.add(key, attributes[key].blank? ? :blank : error)
    end
  end
end

pry-byebugのstepメソッドで処理の内部に入って、適宜処理を実行して挙動の把握を試みます。

$ attributes = attributes.to_h.with_indifferent_access.slice(*required_attributes).delete_if { |key, value| value.blank? }
=> {"email"=>"sample@example.com"}

$ record = find_first_by_auth_conditions(attributes) and return record
=> nil

$   record = new(devise_parameter_filter.filter(attributes)).tap do |record|
    required_attributes.each do |key|
      record.errors.add(key, attributes[key].blank? ? :blank : error)
    end
  end

$ record
=> #<User id: nil, email: "keisuke.koshikawa@gmail.com", unique_session_id: nil, created_at: nil, updated_at: nil>

$ record.errors
=> #<ActiveModel::Errors:0x00007f8eec050488
 @base=#<User id: nil, email: "keisuke.koshikawa@gmail.com", unique_session_id: nil, created_at: nil, updated_at: nil>,
 @details={:email=>[{:error=>:not_found}]},
 @messages={:email=>["not found"]}>

attrを加工して必要なattrと数が一致していればレコードを引いてきて、適宜errorsオブジェクトにエラーを格納していました。

個人的には加工とエラー格納をprivateメソッドにしてメソッドの粒度を細かくしたいなと読んでて思いましたが余談ですね、、、

しかも12年前のコードが現役で動いていることに感動、、、またまた余談でした。

make User#send_reset_password_instructions to require all authenticat… · heartcombo/devise@850afec · GitHub

さて、recordを引いてくる部分のメソッドを読んでみます。


find_first_by_auth_conditions

github.com

def find_first_by_auth_conditions(tainted_conditions, opts = {})
  to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
end

こちらもstepメソッドで適宜実行してみます。

$ tainted_conditions
=> {"email"=>"sample@example.com"}

$ to_adapter
=> #<OrmAdapter::ActiveRecord:0x00007f8ee0094540
 @klass=
  User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime)>

$ devise_parameter_filter
=> #<Devise::ParameterFilter:0x00007f8ee011b810 @case_insensitive_keys=[:email], @strip_whitespace_keys=[:email]>

$  devise_parameter_filter.filter(tainted_conditions)
=> {"email"=>"sample@example.com"}

$ to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
=> nil

From: /usr/local/bundle/gems/orm_adapter-0.5.0/lib/orm_adapter/adapters/active_record.rb:22 OrmAdapter::ActiveRecord#find_first:

    21: def find_first(options = {})
 => 22:   construct_relation(klass, options).first
    23: end

$ klass
=> User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime)

$ options
=> {"email"=>"sample@example.com"}

$  construct_relation(klass, options)
 User Load (5.0ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1  [["email", "sample@example.com"]]
=> []

From: /usr/local/bundle/gems/orm_adapter-0.5.0/lib/orm_adapter/adapters/active_record.rb:42 OrmAdapter::ActiveRecord#construct_relation:

    41: def construct_relation(relation, options)
    42:   conditions, order, limit, offset = extract_conditions!(options)
    43:
    44:   relation = relation.where(conditions_to_fields(conditions))
    45:   relation = relation.order(order_clause(order)) if order.any?
    46:   relation = relation.limit(limit) if limit
    47:   relation = relation.offset(offset) if offset
    48:
=>49:   relation
    50: end

$ relation
 User Load (5.0ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1  [["email", "sample@example.com"]]
=> []

最終的にdeviseのコードではないところのコードになりましたが、、、

find_first_by_auth_conditionsメソッドは引数でユーザーの情報を検索するメソッドでした。

find_or_initialize_with_errorsメソッドの処理もこれで一通り把握できました。


send_reset_password_instructions

ようやくself.send_reset_password_instructionsメソッドの2行目に突入です、、、

github.com

# Resets reset password token and send reset password instructions by email.
# Returns the token sent in the e-mail.
# リセットパスワードトークンをリセットし、パスワードのリセット手順を電子メールで送信します。 
# 電子メールで送信されたトークンが返り値になります。

def send_reset_password_instructions
  token = set_reset_password_token
  send_reset_password_instructions_notification(token)

  token
end

set_reset_password_token

github.com

def set_reset_password_token
  raw, enc = Devise.token_generator.generate(self.class, :reset_password_token)

  self.reset_password_token   = enc
  self.reset_password_sent_at = Time.now.utc
  save(validate: false)
  raw
end

encryptedされたtokenをreset_password_tokenとし、パスワードリセットメール送信日時を設定し、バリデーションを実行せずにレコードをDBに保存しています。

返り値は先ほど訳したコメントアウトにもある通り、トークン(encryptedされてない生の)が返っています。


send_reset_password_instructions_notification

github.com

def send_reset_password_instructions_notification(token)
  send_devise_notification(:reset_password_instructions, token, {})
end

ここからはstepメソッドで処理を追ってみます。

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/recoverable.rb:99 Devise::Models::Recoverable#send_reset_password_instructions_notification:

     98: def send_reset_password_instructions_notification(token)
 =>  99:   send_devise_notification(:reset_password_instructions, token, {})
    100: end

$ step

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/authenticatable.rb:201 Devise::Models::Authenticatable#send_devise_notification:

    200: def send_devise_notification(notification, *args)
 => 201:   message = devise_mailer.send(notification, self, *args)
    202:   # Remove once we move to Rails 4.2+ only.
    203:   if message.respond_to?(:deliver_now)
    204:     message.deliver_now
    205:   else
    206:     message.deliver
    207:   end
    208: end

$ message = devise_mailer.send(notification, self, *args)
=> #<ActionMailer::MessageDelivery:0x2b0923acff60>

DeviseのMailerを呼ぶ処理を実行していました。以下のメソッドが実行されるようです。

def reset_password_instructions(record, token, opts = {})
  @token = token
  devise_mail(record, :reset_password_instructions, opts)
end

devise_mailの処理はこちら。

github.com

これでようやくsend_reset_password_instructionsメソッドも挙動の把握ができました、、、


successfully_sent?

controllerの処理に戻ってこれました、、、もう一度createアクションを記載します。

  # POST /resource/password
  def create
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    else
      respond_with(resource)
    end
  end

successfully_sent?というメソッド名からどんな処理か想像はつきますが、見に行ってみましょう。

github.com

# Helper for use after calling send_*_instructions methods on a resource.
# If we are in paranoid mode, we always act as if the resource was valid
# and instructions were sent.

# リソースでsend_ * _instructionsメソッドを呼び出した後に使用するヘルパー。 
# パラノイドモードの場合、リソースが有効であり、指示が送信されたかのように常に動作します。

def successfully_sent?(resource)
  notice = if Devise.paranoid
    resource.errors.clear
    :send_paranoid_instructions
  elsif resource.errors.empty?
    :send_instructions
  end

  if notice
    set_flash_message! :notice, notice
    true
  end
end

Devise.paranoidをどのように使うかは以下の記事が詳しかったです。

techracho.bpsinc.jp

noticeの値に応じて表示するフラッシュメッセージを変えているようでした。


まとめ

Devise.paranoidは知らない機能でした、、、コード読んでおいてよかった。

個人的には Time.now.utc も気になりました。アプリケーションのタイムゾーン運用によっては軽く問題になるような気もします。

recoverableモジュール入れるだけでパスワードリセットできるやん!viewもちゃちゃっとカスタマイズできるし!とか思ってましたが、

裏でいろんなことをdeviseがやってくれていたということを改めて認識する良い機会になりました。

devise-securityのsession_limitableモジュールのソースコードを追ってみた

こんにちは!kossyです!




さて、今回はdevise-securiyのsession_limitableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise-security 0.16.1



前提

devise-securityのsession_limitableモジュールをincludeしたUserモデルが定義されているものとします。

Devise::Models::SessionLimitable

まずはモジュールのコードをさらっと確認してみます。

# 省略

module Devise
  module Models
    # SessionLimited ensures, that there is only one session usable per account at once.
    # If someone logs in, and some other is logging in with the same credentials,
    # the session from the first one is invalidated and not usable anymore.
    # The first one is redirected to the sign page with a message, telling that
    # someone used his credentials to sign in.
    module SessionLimitable
      extend ActiveSupport::Concern
      include Devise::Models::Compatibility

      # Update the unique_session_id on the model.  This will be checked in
      # the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}
      # @param unique_session_id [String]
      # @return [void]
      # @raise [Devise::Models::Compatibility::NotPersistedError] if record is unsaved
      def update_unique_session_id!(unique_session_id)
        raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?

        update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
          Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
        end
      end

      # Should session_limitable be skipped for this instance?
      # @return [Boolean]
      # @return [false] by default. This can be overridden by application logic as necessary.
      def skip_session_limitable?
        false
      end
    end
  end
end

SessionLimited...から始まる部分をGoogle翻訳で訳してみます。

SessionLimited ensures, that there is only one session usable per account at once.
If someone logs in, and some other is logging in with the same credentials,
the session from the first one is invalidated and not usable anymore.
The first one is redirected to the sign page with a message, telling that someone used his credentials to sign in.

SessionLimitedは、アカウントごとに一度に使用できるセッションが1つだけであることを保証します。
誰かがログインし、他の誰かが同じ資格情報でログインしている場合、
最初のセッションからのセッションは無効になり、使用できなくなります。
最初のセッションは、誰かが自分の資格情報を使用してサインインしたことを通知するメッセージとともにサインページにリダイレクトされます。

平たく言ってしまうと、多重セッションを禁ずるモジュールですね。

上記の機能はどのようにして実現しているのでしょうか。


update_unique_session_id!

Devise::Models::SessionLimitableモジュールには2つのメソッドが定義されていて、そのうちの一つがupdate_unique_session_id!メソッドです。

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

Update the unique_session_id on the model. This will be checked in
the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}

モデル内のunique_session_idをupdateします。
これはWardenのafter_set_userフック内でチェックされます。(fileはdevise-security/hooks/session_limitable)

session_limitableを使うときに必要になる、unique_session_idをWardenのafter_set_userフック内でupdateするメソッドのようです。

  def update_unique_session_id!(unique_session_id)
    raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?

    update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
      Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
    end
  end

このメソッドをUserモデルに定義し、間にbinding.pryを挟んだ上で、ログインを試みます。

From: /app/app/models/supervisor.rb:94 Supervisor#update_unique_session_id!:

    90: def update_unique_session_id!(unique_session_id)
    91:   binding.pry
    92:   raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
    93:
 => 94:   update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
    95:     Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
    96:   end
    97: end

$ unique_session_id
=> nil

$ persisted?
=> true


update_attribute_without_validatons_or_callbacksメソッドを読んでみる必要がありそう。

update_attribute_without_validatons_or_callbacks

pry-byebugを導入していると使える、stepメソッドを使って update_attribute_without_validatons_or_callbacks の処理を見てみます。

From: /usr/local/bundle/gems/devise-security-0.16.0/lib/devise-security/models/compatibility/active_record_patch.rb:34 Devise::Models::Compatibility::ActiveRecordPatch#update_attribute_without_validatons_or_callbacks:

    33: def update_attribute_without_validatons_or_callbacks(name, value)
 => 34:   update_column(name, value)
    35: end

$ name
=> :unique_session_id

$ value
=> "buGqJFwGDV1bmXa39fgY"

ActiveRecordのupdate_columnメソッドを呼び出している処理でした。

update_columnメソッドでattributeの更新を行っているため、updated_at/updated_onの更新は行われません、、、

参考: 週刊Railsウォッチ(20181210)update_columnは要注意、DBカラムコメントは書こう、個人情報扱いの注意点、Capistranoはやっぱりいいほか|TechRacho by BPS株式会社


Warden::Manager.after_set_user

update_unique_session_id!メソッドの呼び出し元はWarden::Managerクラスのafter_set_userフックでした。

# After each sign in, update unique_session_id. This is only triggered when the
# user is explicitly set (with set_user) and on authentication. Retrieving the
# user from session (:fetch) does not trigger it.
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
  if record.devise_modules.include?(:session_limitable) &&
     warden.authenticated?(options[:scope]) &&
     !record.skip_session_limitable?

     if !options[:skip_session_limitable]
      unique_session_id = Devise.friendly_token
      warden.session(options[:scope])['unique_session_id'] = unique_session_id
      record.update_unique_session_id!(unique_session_id)
     else
      warden.session(options[:scope])['devise.skip_session_limitable'] = true
     end
  end
end

skip_session_limitableオプションがfalseでなければ、tokenを生成してwardenのsessionにunique_session_idを代入して、

.update_unique_session_id!メソッドを呼び出しています。

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

After each sign in, update unique_session_id.
This is only triggered when the user is explicitly set (with set_user) and on authentication.
Retrieving the user from session (:fetch) does not trigger it.

サインインするたびに、unique_session_idを更新します。
これは、ユーザーが(set_userを使用して)明示的に設定され、認証された場合にのみトリガーされます。
セッション(:fetch)からユーザーを取得しても、トリガーされません。

呼び出されるタイミングが記載されていました。

skip_session_limitable?

      # Should session_limitable be skipped for this instance?
      # @return [Boolean]
      # @return [false] by default. This can be overridden by application logic as necessary.
      def skip_session_limitable?
        false
      end

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

Should session_limitable be skipped for this instance?
@return [Boolean]
@return [false] by default. This can be overridden by application logic as necessary.

session_limitableをスキップする必要があるかどうかを制御できます。
もしスキップしたい場合は必要に応じてアプリケーションロジックによって上書きできます。

trueを返すようにすれば、unique_session_idカラムの値を更新しなくなるようでした。

まとめ

update_columnを使っているため、timestamp機能が有効でもupdated_at/updated_on属性が更新されない問題があるみたいです、、、

自力で直せるか検討してみたんですが、Railsのtimestamp機能のprivateメソッドを直に呼び出すような案しか思いつきませんでした。

OSSコントリビュートのチャンスだと思うんですが、思ったよりも考慮事項が多そうに思いました。どこかで直したいけど。

devise-securityのsession_limitableを使ってみる

こんにちは!kossyです!




さて、今回は認証機能を提供するGem「devise」のextensionで、エンタープライズな認証機能を実現する

devise-securityのsession_limitableを使ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise-security 0.16.1



session_limitableとは?

1アカウント1セッションであることを強制する、いわゆる「多重セッション」を禁止するモジュールです。

多重セッションの禁止とは、例えばスマホTwitterにログインした状態でPCからTwitterにログインすると、スマホ側のTwitterのセッションが切れることを指します。

導入

既にdeviseの導入とdeviseの各種モジュールをincludeしたモデル(User)が定義されているものとします。

Gemfileにdevise-securiyを追加

gem 'devise-security'

でbundle

$ bundle

Userモデルにsession_limitableモジュールで使うカラムを追加します。

class AddUniqueSessionIdToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :unique_session_id, :string
  end
end

rails db:migrate

$ rails db:migrate

次にUserモデルにsession_limitableモジュールを追加します。

devise :session_limitable

これで導入は完了です。


動作確認

複数ブラウザを立ち上げて動作を確認してみます。

chromeでログイン

f:id:kossy-web-engineer:20211128232921p:plain

その後safariでログイン

f:id:kossy-web-engineer:20211128233024p:plain

その後chromeの方をリロードすると、ログアウト画面に遷移する(sessionが切れている)

f:id:kossy-web-engineer:20211128233058p:plain



動作の確認が取れました。

まとめ

動作確認していて思ったのですが、複数環境でのログインそのものはできるんですね。セッションが生きている状態でログインを試みたら、ログインに失敗する機能かと勘違いしていました。

session_limitableを検索すると、「多重ログイン」という文言が出てくるのですが、

本ブログでは「多重セッション」という言葉を選んでいます。多重ログイン自体は可能のようだからですね。

あと、unique_session_idの更新時にupdated_atの値が更新されていないようでした。

Railsの場合、レコードに変更が加わると自動でupdated_atが更新されるんですが、なぜ更新がされていないのか、原因を追ってみたくなりました。

ruby-samlで「Invalid Signature on SAML Response」が返ってきた場合の調査方法

こんにちは!kossyです!




今回はSSOを実現するGem「ruby-saml」で「Invalid Signature on SAML Response」が返ってきた場合の調査方法について、

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





環境
Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
ruby-saml 1.13.0




なお、サンプルのアプリケーションはoneloginが提供している以下のアプリを使っているものとします。

github.com

コードリーディング

fail.html.erbをレンダリングしている処理はこちらでした。

  def acs
    settings = Account.get_saml_settings(get_url_base)
    response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: settings)

    if response.is_valid?
      session[:nameid] = response.nameid
      session[:attributes] = response.attributes
      @attrs = session[:attributes]
      logger.info "Sucessfully logged"
      logger.info "NAMEID: #{response.nameid}"
      render :action => :index
    else
      logger.info "Response Invalid. Errors: #{response.errors}"
      @errors = response.errors
      render :action => :fail
    end
  end

なので、acsアクションにbinding.pryを定義し、pry-byebug Gemを入れることで使えるようになるstepメソッドを使って、

原因を探りたいと思います。

    20: def acs
    21:   settings = Account.get_saml_settings(get_url_base)
    22:   response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: settings)
    23:
    24:   binding.pry
    25:
 => 26:   if response.is_valid?
    27:     session[:nameid] = response.nameid
    28:     session[:attributes] = response.attributes
    29:     @attrs = session[:attributes]
    30:     logger.info "Sucessfully logged"
    31:     logger.info "NAMEID: #{response.nameid}"
    32:     render :action => :index
    33:   else
    34:     logger.info "Response Invalid. Errors: #{response.errors}"
    35:     @errors = response.errors
    36:     render :action => :fail
    37:   end
    38: end


$ response.is_valid?
=> false

$ step

From: /usr/local/bundle/gems/ruby-saml-1.13.0/lib/onelogin/ruby-saml/response.rb:79 OneLogin::RubySaml::Response#is_valid?:

    78: def is_valid?(collect_errors = false)
 => 79:   validate(collect_errors)
    80: end

$ step

      # Validates the SAML Response (calls several validation methods)
      # @param collect_errors [Boolean] Stop validation when first error appears or keep validating. (if soft=true)
      # @return [Boolean] True if the SAML Response is valid, otherwise False if soft=True
      # @raise [ValidationError] if soft == false and validation fails
      #
      def validate(collect_errors = false)
        reset_errors!
        return false unless validate_response_state

        validations = [
          :validate_version,
          :validate_id,
          :validate_success_status,
          :validate_num_assertion,
          :validate_no_duplicated_attributes,
          :validate_signed_elements,
          :validate_structure,
          :validate_in_response_to,
          :validate_one_conditions,
          :validate_conditions,
          :validate_one_authnstatement,
          :validate_audience,
          :validate_destination,
          :validate_issuer,
          :validate_session_expiration,
          :validate_subject_confirmation,
          :validate_name_id,
          :validate_signature
        ]

        if collect_errors
          validations.each { |validation| send(validation) }
          @errors.empty?
        else
          validations.all? { |validation| send(validation) }
        end
      end

# ですよね()という結果
$  validations.all? { |validation| send(validation) }
=> false

実際にvalidationを実行しているのは、send(validation)の部分ですね。

ここは全て読むのは辛いので、「Invalid Signature」でruby-samlのコードを検索してみます。

      # Validates the Signature
      # @return [Boolean] True if not contains a Signature or if the Signature is valid, otherwise False if soft=True
      # @raise [ValidationError] if soft == false and validation fails
      #
      def validate_signature
        error_msg = "Invalid Signature on SAML Response"

        # If the response contains the signature, and the assertion was encrypted, validate the original SAML Response
        # otherwise, review if the decrypted assertion contains a signature
        sig_elements = REXML::XPath.match(
          document,
          "/p:Response[@ID=$id]/ds:Signature",
          { "p" => PROTOCOL, "ds" => DSIG },
          { 'id' => document.signed_element_id }
        )

        use_original = sig_elements.size == 1 || decrypted_document.nil?
        doc = use_original ? document : decrypted_document

        # Check signature nodes
        if sig_elements.nil? || sig_elements.size == 0
          sig_elements = REXML::XPath.match(
            doc,
            "/p:Response/a:Assertion[@ID=$id]/ds:Signature",
            {"p" => PROTOCOL, "a" => ASSERTION, "ds"=>DSIG},
            { 'id' => doc.signed_element_id }
          )
        end

        if sig_elements.size != 1
          if sig_elements.size == 0
             append_error("Signed element id ##{doc.signed_element_id} is not found")
          else
             append_error("Signed element id ##{doc.signed_element_id} is found more than once")
          end
          return append_error(error_msg)
        end

        old_errors = @errors.clone

        idp_certs = settings.get_idp_cert_multi
        if idp_certs.nil? || idp_certs[:signing].empty?
          opts = {}
          opts[:fingerprint_alg] = settings.idp_cert_fingerprint_algorithm
          idp_cert = settings.get_idp_cert
          fingerprint = settings.get_fingerprint
          opts[:cert] = idp_cert

          if fingerprint && doc.validate_document(fingerprint, @soft, opts)
            if settings.security[:check_idp_cert_expiration]
              if OneLogin::RubySaml::Utils.is_cert_expired(idp_cert)
                error_msg = "IdP x509 certificate expired"
                return append_error(error_msg)
              end
            end
          else
            return append_error(error_msg)
          end
        else
          valid = false
          expired = false
          idp_certs[:signing].each do |idp_cert|
            valid = doc.validate_document_with_cert(idp_cert, true)
            if valid
              if settings.security[:check_idp_cert_expiration]
                if OneLogin::RubySaml::Utils.is_cert_expired(idp_cert)
                  expired = true
                end
              end

              # At least one certificate is valid, restore the old accumulated errors
              @errors = old_errors
              break
            end

          end
          if expired
            error_msg = "IdP x509 certificate expired"
            return append_error(error_msg)
          end
          unless valid
            # Remove duplicated errors
            @errors = @errors.uniq
            return append_error(error_msg)
          end
        end

        true
      end


validate_signatureメソッドがエラーメッセージを返していそうなので、処理を追ってみます。

$ send(:validate_signature)
=> false

# stepでvalidate_signatureメソッドの内部に移動

# どうやらここがfalseなのが原因
$ if fingerprint && doc.validate_document(fingerprint, @soft, opts)
=> false

$ fingerprint
=> "AB:CD:EF:AB:CD:EF:A1:B2:C3:D4:E5:F6:94:C1:B5:8B:00:23:0A:D8:3D:1B:55:DF:41:EA:42:5F:9E:62:07:85"

$ doc.validate_document(fingerprint, @soft, opts)
=> false

どうやらfingerprintが間違っているのが原因のようです。

    # IdP section
    settings.idp_entity_id                  = "#{idp_base_url}/#{ENV['IDP_GROUP_NAME']}"
    settings.idp_sso_target_url             = "#{idp_base_url}/idp/#{ENV['IDP_GROUP_NAME']}"
    settings.idp_slo_target_url             = "#{idp_base_url}/idp/#{ENV['IDP_GROUP_NAME']}/logout"
    settings.idp_cert_fingerprint           = "#{ENV['IDP_FINGERPRINT']}"
    settings.idp_cert_fingerprint_algorithm = XMLSecurity::Document::SHA256

settings.idp_cert_fingerprint属性の値に正しいfingerprintを渡してやればOKでした。


まとめ

もし本番運用で「Invalid Signature on SAML Response」に遭遇して、原因が「fingerprintの値が間違っている」だったとして、

原因を特定するまでにかなりの労力を使うような気がしています、、、(エラーメッセージが不親切)

XmlSecurityモジュールのvalidate_documentメソッドも読んでみようと思ったんですが、何をしているのかさっぱりわからなかったので、

Pull Requestのコメントやcommitメッセージを読みつつコードリーディングを進めるのと、

そもそもなぜSAMLによるSSO認証にfingerprintが使われているか、等を調べて知見を深めようと思います、、、

ruby-samlで「Invalid settings, idp_sso_service_url is not set!」が発生した場合の対処法

こんにちは!kossyです!




今回はSSOを実現するGem「ruby-saml」で「Invalid settings, idp_sso_service_url is not set!」が発生した場合の対処法について、

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





環境
Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
ruby-saml 1.13.0



エラーの発生箇所のコードリーディング

まずはどこで例外が投げられているのか、binding.pryで処理を止めて、

pry-byebug Gemを入れると使用できるstepコマンドを使いながら確認してみます。

class SamlController < ApplicationController
  skip_before_action :verify_authenticity_token, :only => [:acs, :logout]

  # 省略

  def sso
    settings = Account.get_saml_settings(get_url_base)
    if settings.nil?
      render :action => :no_settings
      return
    end

    request = OneLogin::RubySaml::Authrequest.new

    binding.pry
    redirect_to(request.create(settings))
  end

  # 省略
end

localhost:3000/saml/sso にアクセスすると、処理が止まります。

From: /app/app/controllers/saml_controller.rb:18 SamlController#sso:

     8: def sso
     9:   settings = Account.get_saml_settings(get_url_base)
    10:   if settings.nil?
    11:     render :action => :no_settings
    12:     return
    13:   end
    14:
    15:   request = OneLogin::RubySaml::Authrequest.new
    16:
    17:   binding.pry
 => 18:   redirect_to(request.create(settings))
    19: end

この状態でstepコマンドでcreateメソッドの処理を見に行ってみます。

From: /usr/local/bundle/gems/ruby-saml-1.13.0/lib/onelogin/ruby-saml/authrequest.rb:37 OneLogin::RubySaml::Authrequest#create:

    36: def create(settings, params = {})
 => 37:   params = create_params(settings, params)
    38:   params_prefix = (settings.idp_sso_service_url =~ /\?/) ? '&' : '?'
    39:   saml_request = CGI.escape(params.delete("SAMLRequest"))
    40:   request_params = "#{params_prefix}SAMLRequest=#{saml_request}"
    41:   params.each_pair do |key, value|
    42:     request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
    43:   end
    44:   raise SettingError.new "Invalid settings, idp_sso_service_url is not set!" if settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
    45:   @login_url = settings.idp_sso_service_url + request_params
    46: end

44行目が今回の例外をraiseしているところですね。

$  settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
=> true

true が返りました。 タイトルの例外が生じた原因は、settingsオブジェクトのidp_sso_service_url属性がnilまたは空だったからですね。

idp_sso_service_urlはsettingsオブジェクトのメソッドになっていました。

github.com

      # @return [String] IdP Single Sign On Service URL
      #
      def idp_sso_service_url
        @idp_sso_service_url || @idp_sso_target_url
      end

idp_sso_service_url属性がDBに保存されているか、はたまたconfig値を引っ張ってきているかは各々のアプリケーション次第かと思いますが、

settingsオブジェクトの生成時に、idp_sso_service_url属性に値が必ず入るようにすればOKそうですね。


まとめ

ruby-samlのReadMe通りに実装してテストしているときにはあまり遭遇しないかもしれませんが、

DBから値を引っ張ってくるような構成の場合は、きちんと制約をかけてあげていないと、ハマってしまうかもしれませんね。

github.com


大いに参考にさせていただいたサイト

素晴らしいコンテンツの提供、誠にありがとうございます。

ruby-saml/README.md at master · onelogin/ruby-saml · GitHub

devise_saml_authenticatableのSamlAuthenticatable::SamlResponseのソースコードを追ってみる

こんにちは!kossyです!




今回は、前回のブログで追いきれなかった、SamlAuthenticatable::SamlResponseのソースコードを追ってみようと思います。

前回のブログ

kossy-web-engineer.hatenablog.com



環境
Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise_saml_authenticatable 1.7.0




呼び出し元のおさらい

呼び出し元の処理はこちらでした。

github.com

def authenticate_with_saml(saml_response, relay_state)
  key = Devise.saml_default_user_key
  decorated_response = ::SamlAuthenticatable::SamlResponse.new(
    saml_response,
    attribute_map(saml_response),
  )
  if Devise.saml_use_subject
    auth_value = saml_response.name_id
  else
    auth_value = decorated_response.attribute_value_by_resource_key(key)
  end
  auth_value.try(:downcase!) if Devise.case_insensitive_keys.include?(key)

  resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)

  raise "Only one validator configuration can be used at a time" if Devise.saml_resource_validator && Devise.saml_resource_validator_hook
  if Devise.saml_resource_validator || Devise.saml_resource_validator_hook
    valid = if Devise.saml_resource_validator then Devise.saml_resource_validator.new.validate(resource, saml_response)
            else Devise.saml_resource_validator_hook.call(resource, decorated_response, auth_value)
            end
    if !valid
      logger.info("User(#{auth_value}) did not pass custom validation.")
      return nil
    end
  end

  if resource.nil?
    if Devise.saml_create_user
      logger.info("Creating user(#{auth_value}).")
      resource = new
    else
      logger.info("User(#{auth_value}) not found.  Not configured to create the user.")
      return nil
    end
  end

  if Devise.saml_update_user || (resource.new_record? && Devise.saml_create_user)
    Devise.saml_update_resource_hook.call(resource, decorated_response, auth_value)
  end

  resource
end

authenticate_with_samlメソッドの中で、SamlAuthenticatable::SamlResponse.newが呼び出されていましたね。


SamlAuthenticatable::SamlResponseクラス

github.com

改行を含めても16行の小さいクラスでした。

require 'devise_saml_authenticatable/saml_mapped_attributes'

module SamlAuthenticatable
  class SamlResponse
    attr_reader :raw_response, :attributes

    def initialize(saml_response, attribute_map)
      @attributes = ::SamlAuthenticatable::SamlMappedAttributes.new(saml_response.attributes, attribute_map)
      @raw_response = saml_response
    end

    def attribute_value_by_resource_key(key)
      attributes.value_by_resource_key(key)
    end
  end
end

initializeメソッドの中で SamlAuthenticatable::SamlMappedAttributesというクラスがnewされていますので、処理を見てみます。

と思ったんですが、authenticate_with_samlメソッドの中でattribute_mapというメソッドが呼ばれてますね、、、こちらから見てみましょうか。

attribute_map

定義元はSamlAuthenticatableモジュールの84行目です。

github.com

def attribute_map(saml_response = nil)
  attribute_map_resolver.new(saml_response).attribute_map
end

def attribute_map_resolver
  if Devise.saml_attribute_map_resolver.respond_to?(:new)
    Devise.saml_attribute_map_resolver
  else
    Devise.saml_attribute_map_resolver.constantize
  end
end

Devise.saml_attribute_map_resolverに対して、newが定義されていれば Devise.saml_attribute_map_resolver.newを呼び出し、

newが定義されていなければ Devise.saml_attribute_map_resolverをcontantizeメソッドで文字列をクラス化した後にnewを呼び出していました。

github.com

module DeviseSamlAuthenticatable
  class DefaultAttributeMapResolver
    def initialize(saml_response)
      @saml_response = saml_response
    end

    def attribute_map
      return {} unless File.exist?(attribute_map_path)

      attribute_map = YAML.load(File.read(attribute_map_path))
      if attribute_map.key?(Rails.env)
        attribute_map[Rails.env]
      else
        attribute_map
      end
    end

    private

    attr_reader :saml_response

    def attribute_map_path
      Rails.root.join("config", "attribute-map.yml")
    end
  end
end

initializeメソッドでは引数で渡されたsaml_responseをインスタンス変数に代入しているだけですね。

attribute_mapメソッドは、configディレクトリ直下にattribute_map.ymlファイルが存在していなければ {} を返却し、

存在していれば、YAML.loadで読み出して、attribute_mal.ymlにproduction: のように環境毎に定義が分かれていれば、その環境の値を読み出し、

定義が分かれていなければ全てのattribute_map.ymlの値を返しています。この実装方法、他にも応用が効きそうな気がしています。

attribute_map.ymlファイルの中身はこんな感じになるかと思います。

"urn:mace:dir:attribute-def:email": "email"

SAMLの属性とdeviseの属性をmappingする用途で使っています。

この辺りはコンソールで試してみましょうか。

# Instead of storing the attribute_map in attribute-map.yml, store it in the database, or set it programatically
# attribute_mapをattribute-map.ymlに保存する代わりに、データベースに保存するか、プログラムで設定します

# 私の環境ではfalseが返りました。
$ Devise.saml_attribute_map_resolver.respond_to?(:new)
=> false

# newは DeviseSamlAuthenticatable::DefaultAttributeMapResolver に対して呼び出される
$ Devise.saml_attribute_map_resolver.constantize
=> DeviseSamlAuthenticatable::DefaultAttributeMapResolver

# 私の環境ではattribute-map.ymlを定義しているので返り値が存在する
$ attribute_map_path
=> #<Pathname:/app/config/attribute-map.yml>

$ File.exist?(attribute_map_path)
=> true

$ attribute_map
=> {"urn:mace:dir:attribute-def:email"=>"email"}

attribute_mapメソッドの動きが理解できたところで、 SamlAuthenticatable::SamlResponseクラスに戻ります。

SamlAuthenticatable::SamlMappedAttributesクラス

deviseとsaml_responseのattrのmappingを担当するクラスですね。

github.com

module SamlAuthenticatable
  class SamlMappedAttributes
    def initialize(attributes, attribute_map)
      @attributes = attributes
      @attribute_map = attribute_map
    end

    def saml_attribute_keys
      @attribute_map.keys
    end

    def resource_keys
      @attribute_map.values
    end

    def value_by_resource_key(key)
      str_key = String(key)

      # Find all of the SAML attributes that map to the resource key
      attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }

      saml_value = nil

      # Find the first non-nil value
      attribute_map_for_key.each_key do |saml_key|
        saml_value = value_by_saml_attribute_key(saml_key)

        break unless saml_value.nil?
      end

      saml_value
    end

    def value_by_saml_attribute_key(key)
      @attributes[String(key)]
    end
  end
end

initialize時に引数で渡ってきたSamlResponseの属性を基に、attribute_mapの値と比較しつつ、合致した値を返却するような処理が含まれていました。

こちらもコンソールで試してみましょう。

$ decorated_response.attribute_value_by_resource_key(key)
=> nil

# stepでメソッド内部に入り込んでなぜnilが返るのか検証してみました

    16: def value_by_resource_key(key)
 => 17:   str_key = String(key)
    18:
    19:   # Find all of the SAML attributes that map to the resource key
    20:   attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }
    21:
    22:   saml_value = nil
    23:
    24:   # Find the first non-nil value
    25:   attribute_map_for_key.each_key do |saml_key|
    26:     saml_value = value_by_saml_attribute_key(saml_key)
    27:
    28:     break unless saml_value.nil?
    29:   end
    30:
    31:   saml_value
    32: end

$ key
=> :email

$ str_key = String(key)
=> "email"

$  attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }
=> {"urn:mace:dir:attribute-def:email"=>"email"}

    34: def value_by_saml_attribute_key(key)
 => 35:   @attributes[String(key)]
    36: end

# @attributesがそもそも{}だったからでした
$  @attributes[String(key)]
=> nil

概ね挙動の理解ができました。

まとめ

SAMLResponseとdeviseのattrのマッピングの実現方法は、普段の実装でも活かせるような気がしています。(ymlファイルの読み込み等々)

一通り挙動を追ってみて思ったのですが、やはりSAMLによるSSO周りの仕様の理解が追いついてないのをコードを読めば読むほど痛感しますね、、、

近いうちに、「この値を設定していないとこのレスポンスが返ってくる」とか、
「この値が間違っているとこのエラーになる」みたいなのを体当たりで検証してみたいと思います。(IdP側のコードも読んでみたいな、、、)

DeviseベースでSAML SSOを実現する 「devise_saml_authenticatable」のソースコードを追ってみた

こんにちは!kossyです!




今回は、DeviseベースでSAML SSOを実現する 「devise_saml_authenticatable」のソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise 4.8.0
devise_saml_authenticatable 1.7.0



newアクション

github.com

上記のセクションを見ると、

The attribute mappings are very dependent on the way the IdP encodes the attributes. In these examples the attributes are given in URN style. Other IdPs might provide them as OID's, or by other means.

属性マッピングは、IdPが属性をエンコードする方法に大きく依存します。 これらの例では、属性はURNスタイルで指定されています。 他のIdPは、それらをOIDとして、または他の手段で提供する場合があります。

You are now ready to test it against an IdP.

これで、IdPに対してテストする準備が整いました。

When the user visits /users/saml/sign_in they will be redirected to the login page of the IdP.

ユーザーが/ users / saml / sign_inにアクセスすると、IdPのログインページにリダイレクトされます。

Upon successful login the user is redirected to the Devise user_root_path.

ログインに成功すると、ユーザーはDeviseのuser_root_pathにリダイレクトされます。

出典: https://github.com/apokalipto/devise_saml_authenticatable#configuring-handling-of-idp-requests-and-responses

とのことなので、まずはsaml_sessions_controller.rbのnewアクションのソースコードから追ってみます。

def new
  idp_entity_id = get_idp_entity_id(params)
  request = OneLogin::RubySaml::Authrequest.new
  auth_params = { RelayState: relay_state } if relay_state
  action = request.create(saml_config(idp_entity_id), auth_params || {})
  if request.respond_to?(:request_id)
    session[:saml_transaction_id] = request.request_id
  end
  redirect_to action
end

まずはget_idp_entity_idメソッドから見てみます。

def get_idp_entity_id(params)
  idp_entity_id_reader.entity_id(params)
end

# idp_entity_id_readerメソッドはこちら
def idp_entity_id_reader
  if Devise.idp_entity_id_reader.respond_to?(:entity_id)
    Devise.idp_entity_id_reader
  else
    @idp_entity_id_reader ||= Devise.idp_entity_id_reader.constantize
  end
end

Devise Moduleのidp_entity_id_readerにentity_idが生えて入れば、idp_entity_id_readerを返却し、

そうでなければDevise.idp_entity_id_reader.constantizeを実行してインスタンス変数としています。(DeviseSamlAuthenticatable::DefaultIdpEntityIdReaderクラスが返ります)

DeviseSamlAuthenticatable::DefaultIdpEntityIdReaderとはどんなクラスでしょうか。

module DeviseSamlAuthenticatable
  class DefaultIdpEntityIdReader
    def self.entity_id(params)
      if params[:SAMLRequest]
        OneLogin::RubySaml::SloLogoutrequest.new(
          params[:SAMLRequest],
          settings: Devise.saml_config,
          allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
        ).issuer
      elsif params[:SAMLResponse]
        OneLogin::RubySaml::Response.new(
          params[:SAMLResponse],
          settings: Devise.saml_config,
          allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
        ).issuers.first
      end
    end
  end
end

paramsを見てSAMLのログアウトリクエスインスタンスを生成してissuerを返却するか、

SAMLレスポンスのissuerを返却するかを司るクラスでした。

binding.pryで処理を止めて試したところ、idp_entity_idはnilが返りました。(ifにもelsifにも引っかからないので当然ですが)

その後の処理はSAMLRequestを生成して、SAMLのConfigを元にURL(= action)を生成してそのURLにリダイレクトさせています。

URLは以下のようなものです。

"https://idp_endpoint.com/saml2/idp/?SAMLRequest=hVPBjtowEP2V3HwKMSHb2bUIEgVVRdq2CNIe9rJynEmx4tipZ1Lo39cJUHFo6SGy5HnvzZvnyRxlazqx7Olgd%2FCjB6RoiQietLMrZ7Fvwe%2FB%2F9QKvu5ecnYg6kSSGKekOTgkMeOcJ31gYDJoJTJIsWgdhLSVg8qZg4E0lCZH5xtsXakNTJRrR1Ka6KpLGtDYN9A4POhGHiWLNuucvXFeVTWvVfyclTzOnrIsLqfveFyWz2n46qcH%2FhigiD1sLJK0lLOUp9N4yuMZL%2FiDmD2KjL%2ByaOsdOeXMe20rbb%2FnrPdWOIkahZUtoCAl9stPLyKdcFGeQSg%2BFsU23n7ZFyz6FqYcJwoAFp1aY1EM%2Fu8ryWuet5TuPqe7WGWL%2BYAW43h%2B8d%2F0WyBZSZLz5JY2P7%2Fy59Bms946o9WvaGmMO648SIKcke%2BBRR%2BcbyXdNzbc6CquR6ggLy1qsMSSa5PLFkE17lRYIYITRSvXdtJrHMKDk1R0HewWtTIhqh3Ui7tpKqEGXLjehiOsUzW8K6jQshjsdM7TZfy%2Fip9r%2FzD6p3r7Ryx%2BAw%3D%3D"

OneLogin::RubySaml::Authrequestのコードリーディングは以前書いた拙著でも取り上げています。

kossy-web-engineer.hatenablog.com

createアクション

saml_sessions_controller.rbにはcreateアクションは実装されていませんので、Devise::SessionsControllerのcreateアクションを見てみます。

  # POST /resource/sign_in
  def create
    self.resource = warden.authenticate!(auth_options)
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    respond_with resource, location: after_sign_in_path_for(resource)
  end

createアクションの肝はwarden.authenticate!です。

こちら、まずはbinding.pryでデバッグしながらコードを追ってみます。

  # POST /resource/sign_in
  def create
    binding.pry
    self.resource = warden.authenticate!(auth_options)
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    respond_with resource, location: after_sign_in_path_for(resource)
  end

/users/saml/sign_in にアクセスして、IdP側で認証処理を行った後、SPにリダイレクトするときにcreateアクションが呼ばれます。

pry-byebug Gemのstepメソッドを利用してwarden.authenticate!の処理の詳細を追ってみます。

$ step

$ From: /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:133 Warden::Proxy#authenticate!:

def authenticate!(*args)
  user, opts = _perform_authentication(*args)
  throw(:warden, opts) unless user
  user
end

# 何度かstepを実行

$ /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:354 Warden::Proxy#_run_strategies_for:

# Run the strategies for a given scope
def _run_strategies_for(scope, args) #:nodoc:
  self.winning_strategy = @winning_strategies[scope]
  return if winning_strategy && winning_strategy.halted?

  # Do not run any strategy if locked
  return if @locked

  if args.empty?
    defaults   = @config[:default_strategies]
    strategies = defaults[scope] || defaults[:_all]
  end

  (strategies || args).each do |name|
    strategy = _fetch_strategy(name, scope)
    next unless strategy && !strategy.performed? && strategy.valid?
    catch(:warden) do
      _update_winning_strategy(strategy, scope)
    end

    strategy._run!
    _update_winning_strategy(strategy, scope)
    break if strategy.halted?
  end
end

$ self.winning_strategy = @winning_strategies[scope]
=> nil

$ args.empty?
=> true

$ defaults   = @config[:default_strategies]
=> {:user=>[:saml_authenticatable, :rememberable, :database_authenticatable]} # Userモデルの devise メソッドに指定したモジュールの一部が列挙されます

$  strategies = defaults[scope] || defaults[:_all]
=> [:saml_authenticatable, :rememberable, :database_authenticatable]

# 何度かstep

$ /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:380 Warden::Proxy#_fetch_strategy:

# Fetches strategies and keep them in a hash cache.
def _fetch_strategy(name, scope)
  @strategies[scope][name] ||= if klass = Warden::Strategies[name]
    klass.new(@env, scope)
  elsif @config.silence_missing_strategies?
    nil
  else
    raise "Invalid strategy #{name}"
  end
end

$ @strategies[scope][name]
=> nil

$ klass = Warden::Strategies[name]
=> :saml_authenticatable

# 何度かstepし _run_strategies_for に戻る

$ strategy.class
=>  Devise::Strategies::SamlAuthenticatable

ここでようやく Devise::Strategies::SamlAuthenticatable クラスのインスタンスが登場しました。

この後、 strategy.valid? で Devise::Strategies::SamlAuthenticatableのvalid?メソッドが呼ばれます。

github.com

def valid?
  if params[:SAMLResponse]
    OneLogin::RubySaml::Response.new(
      params[:SAMLResponse],
      response_options,
    )
  else
    false
  end
end

params[:SAMLResponse]が存在していれば OneLogin::RubySaml::Responseインスタンスが返り値となり、なければfalseが返ります。

余談なんですが、Rubyは基本的にメソッドの接尾辞に?がついている場合はTrue or False を返す慣習があると思っていたのですが、

当該コードはその慣習に従ってないですね、、、少しモヤモヤします。

また何度かstepとnextを実行していると、 Devise::Strategies::SamlAuthenticatable#authenticate!が実行されます。

def authenticate!
  parse_saml_response
  retrieve_resource unless self.halted?
  unless self.halted?
    @resource.after_saml_authentication(@response.sessionindex)
    success!(@resource)
  end
end

SamlResponseをparseして、 strategyの実行が中止されていなければ retrieve_resource を実行しています。

halted?はwardenのコードです。

github.com

def retrieve_resource
  @resource = mapping.to.authenticate_with_saml(@response, params[:RelayState])
  if @resource.nil?
    failed_auth("Resource could not be found")
  end
end

mappingは一体何でしょう。

$ mapping.class
=> Devise::Mapping

$  /usr/local/bundle/gems/devise-4.8.0/lib/devise/strategies/base.rb:14 Devise::Strategies::Base#mapping:

def mapping
  @mapping ||= begin
    mapping = Devise.mappings[scope]
    raise "Could not find mapping for #{scope}" unless mapping
    mapping
  end
end

Deviseのリソースとルーティングをマッピングするクラスのインスタンスが返りました。

mapping.toを実行してみます。

$  @mapping.to
=> User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, created_at: datetime, updated_at: datetime)

Userクラスがcallされています。なので、authenticate_with_samlはUserモデルに対して実行されることになりますね。

def authenticate_with_saml(saml_response, relay_state)
  key = Devise.saml_default_user_key
  decorated_response = ::SamlAuthenticatable::SamlResponse.new(
    saml_response,
    attribute_map(saml_response),
  )
  if Devise.saml_use_subject
    auth_value = saml_response.name_id
  else
    auth_value = decorated_response.attribute_value_by_resource_key(key)
  end
  auth_value.try(:downcase!) if Devise.case_insensitive_keys.include?(key)

  resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)

  raise "Only one validator configuration can be used at a time" if Devise.saml_resource_validator && Devise.saml_resource_validator_hook
  if Devise.saml_resource_validator || Devise.saml_resource_validator_hook
    valid = if Devise.saml_resource_validator then Devise.saml_resource_validator.new.validate(resource, saml_response)
            else Devise.saml_resource_validator_hook.call(resource, decorated_response, auth_value)
            end
    if !valid
      logger.info("User(#{auth_value}) did not pass custom validation.")
      return nil
    end
  end

  if resource.nil?
    if Devise.saml_create_user
      logger.info("Creating user(#{auth_value}).")
      resource = new
    else
      logger.info("User(#{auth_value}) not found.  Not configured to create the user.")
      return nil
    end
  end

  if Devise.saml_update_user || (resource.new_record? && Devise.saml_create_user)
    Devise.saml_update_resource_hook.call(resource, decorated_response, auth_value)
  end

  resource
end

ここがdevise_saml_authenticatableを使ったSSOのログインの仕組みの肝だと思うのでじーっくり読んでみます。

# デフォルトのユーザーキーを設定します。 ユーザーはこのキーで検索されます。 認証応答に属性が含まれていることを確認してください。
$ key = Devise.saml_default_user_key
=> :email

# SAMLのレスポンスを加工
$ decorated_response = ::SamlAuthenticatable::SamlResponse.new(saml_response, attribute_map(saml_response))

# この値を設定して、電子メールを比較する情報としてSubjectまたはSAMLアサーションを使用できます。 設定しない場合、EメールはSAMLアサーション属性から抽出されます。
$ Devise.saml_use_subject
=> true

$ auth_value = saml_response.name_id
=> "your_idp_id@your_idp_domain"

# 大文字と小文字を区別しない認証キーを構成します。 これらのキーは、ユーザーの作成または変更時、およびユーザーの認証または検索に使用されるときに小文字になります。 デフォルトは:emailです。
$ auth_value.try(:downcase!) if Devise.case_insensitive_keys.include?(key)

$ resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)
=> #<User id: 1, email: "your_idp_id@your_idp_domain", created_at: "2021-10-17 13:42:43", updated_at: "2021-11-12 13:18:25">

# 取得したリソースと応答を取得直後に取得し、有効な場合はtrueを返す#validateメソッドを実装します。 
# Falseを指定すると、認証が失敗します。 saml_resource_validatorとsaml_resource_validator_hookのいずれか1つのみを使用できます。
# 両方とも Trueだと、「一度に使用できるバリデーター構成は1つだけです」というエラーがraiseします。
$ Devise.saml_resource_validator
=> nil

$ Devise.saml_resource_validator_hook
=> nil

# 自分の環境では両方nilだったためvalidateは省略

$ resource.nil?
=> false

# ログインに成功した後、ユーザーの属性を更新します。 (デフォルトはfalse)
$ Devise.saml_update_user
=> false

$ (resource.new_record? && Devise.saml_create_user)
=> false

一通り追ってみましたが、実際にユーザーを取得しているっぽい処理はDevise.saml_resource_locator.callメソッドだと思われるので、stepでどんな処理をしているか見に行ってみます。

# デフォルトのリソースロケーター。 saml_default_user_keyとauth_valueを使用してユーザーをresolveします。 詳細については、saml_resource_locatorを参照してください。
# /lib/devise_saml_authenticatable.rb:127

mattr_reader :saml_default_resource_locator
@@saml_default_resource_locator = Proc.new do |model, saml_response, auth_value|
  model.where(Devise.saml_default_user_key => auth_value).first
end

$ model
=> User(id: integer, email: string, ...

$ saml_response.class
=> SamlAuthenticatable::SamlResponse

$ auth_value
=> IdP側のemailアドレスが返ります# ユーザーを検索しに行く
$ model.where(Devise.saml_default_user_key => auth_value).first
=> #<User id: 1, email: "idp_mailaddress"

なるほどモデル名に対してwhereメソッドでDevise.saml_default_user_keyに指定した属性をauth_valueで検索しに行く処理でした。

これも余談ですが、where + first は find_by で置き換えられると思うので、細かいですが修正PRを出してもいいかもしれないですね、、、

これで概ね追えたと思います。SamlAuthenticatable::SamlResponseクラスの処理や、attribute-map.yml周りのコードは別の記事で追ってみたいと思います。(もう12000字超えてるし、、、)

Rack::MockRequestとRack::Requestを使ってRack::Requestのbodyのrack.inputをいじっていて気づいたこと

こんにちは!kossyです!




今回はRack::MockRequestのenv_forメソッドとRack::Requestクラスを使って、Rack::Requestのbodyのrack.inputをいじっていて気づいたことがあったので、
備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur




Rack::MockRequestを使ってenvを生成する

rack gemにはデフォルトで Rack::MockRequest というクラスが定義されています。

www.rubydoc.info

github.com

Rack::MockRequestのenv_forメソッドを使えばenvオブジェクトを生成することができるので、まずはこちらを試してみます。

# rails c

$ env = Rack::MockRequest.env_for("/", {})
=> {"rack.version"=>[1, 3],
 "rack.input"=>#<StringIO:0x000055e41ae646b0>,
 "rack.errors"=>#<StringIO:0x000055e41ae64750>,
 "rack.multithread"=>true,
 "rack.multiprocess"=>true,
 "rack.run_once"=>false,
 "REQUEST_METHOD"=>"GET",
 "SERVER_NAME"=>"example.org",
 "SERVER_PORT"=>"80",
 "QUERY_STRING"=>"",
 "PATH_INFO"=>"/",
 "rack.url_scheme"=>"http",
 "HTTPS"=>"off",
 "SCRIPT_NAME"=>"",
 "CONTENT_LENGTH"=>"0"}

envのrack.inputに日本語の文字列を渡してみます。

$ env["rack.input"].string
=> ""

$ env.merge!(::Rack::RACK_INPUT => StringIO.new("その辺にいるWebエンジニアの備忘録"))

$ env["rack.input"].string
=> "その辺にいるWebエンジニアの備忘録"

env["rack.input"]に日本語の文字列を入れることができました。


Rack::Requestのbodyの日本語文字列をreadで読み出してみる

github.com

次に、Rack::Requestインスタンスを生成して、先ほど渡した日本語文字列をreadメソッドで読み出してみます。

$ request = Rack::Request.new(env)

# envで文字列を参照できる
$ request.env['rack.input'].string
=> "その辺にいるWebエンジニアの備忘録"

# bodyはStringIOクラスのインスタンスが格納されている
$ request.body
=> #<StringIO:0x000055e41e96a8b8>

# 4096はバイト数
$ data = request.body.read(4096)
=> "\xE3\x81\x9D\xE3\x81\xAE\xE8\xBE\xBA\xE3\x81\xAB\xE3\x81\x84\xE3\x82\x8BWeb\xE3\x82\xA8\xE3\x83\xB3\xE3\x82\xB8\xE3\x83\x8B\xE3\x82\xA2\xE3\x81\xAE\xE5\x82\x99\xE5\xBF\x98\xE9\x8C\xB2"

非ASCII文字はエスケープされていますね。この文字列をJSON.generateしようとすると以下のエラーが発生します。

$ JSON.generate(data)
=> Encoding::UndefinedConversionError: "\xE3" from ASCII-8BIT to UTF-8

同様の問題がsentry-rubyというGemで発生していたようです。

github.com

修正されたPRでは、非ASCII文字をUTF-8エンコーディングしていました。

$ encoded_data = data.force_encoding(Encoding::UTF_8)
=> "その辺にいるWebエンジニアの備忘録"

rack.inputに非ASCII文字を渡してreadでバイト指定して読み出す時は気をつけるようにしましょう。。。


まとめ

Rack便利!と思う反面きちんと挙動を理解しないと思わぬところでハマってしまうので、

完全にブラックボックスな状態で使うのはやはり怖いと思いました。

Gemのコード読んでおいてよかった、、、

graphql-rubyでCustom Scalarsを定義してみる

こんにちは!kossyです!




今回はgraphql-rubyでCustom Scalarsを定義する方法について、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
graphql-ruby 1.12.19



CustomScalarsとは

graphqlにはデフォルトで組み込まれているいくつかのscalar値があります。

String, like a JSON or Ruby string
Int, like a JSON or Ruby integer
Float, like a JSON or Ruby floating point decimal
Boolean, like a JSON or Ruby boolean (true or false)
ID, which a specialized String for representing unique object identifiers
ISO8601DateTime, an ISO 8601-encoded datetime
ISO8601Date, an ISO 8601-encoded date
JSON, ⚠ This returns arbitrary JSON (Ruby hashes, arrays, strings, integers, floats, booleans and nils). Take care: by using this type, you completely lose all GraphQL type safety. Consider building object types for your data instead.
BigInt, a numeric value which may exceed the size of a 32-bit integer

出典: GraphQL - Scalars

上記以外でも、scalarsを開発者が独自に定義することができます。それがCustomScalarsです。

CustomScalarsの定義

CustomScalarsを定義するには、Types::BaseScalarを継承したクラスを作成する必要があります。

module Types
  class PhoneNumber < Types::BaseScalar
    description 'PhoneNumber'

    def self.coerce_input(input_value, _context)
      if input_value.match?(/\A\d{10,11}\z/)
        input_value
      else
        raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid Phone Number."
      end
    end

    def self.coerce_result(ruby_value, context)
      ruby_value.to_s
    end
  end
end

このように定義を行い、

module Types
  class UserInfoInputType < Types::BaseInputObject
    argument :phone_number, Types::PhoneNumber, required: true
  end
end

のようにargument等で指定することによって適用することができます。

graphiql等のGrapQL Clientで試した結果は以下です。

{
  "errors": [
    {
      "message": "Variable $userInfoInput of type UserInfoInputForm! was provided invalid value for userInfo.phoneNumber (\"タイトル\" is not a valid Phone Number.)",
      "locations": [
        {
          "line": 1,
          "column": 39
        }
      ],
      "extensions": {
        "value": {
          "userInfo": {
            "phoneNumber": "タイトル"
          }
        },
        "problems": [
          {
            "path": [
              "userInfo",
            ],
            "explanation": "\"タイトル\" is not a valid Phone Number.",
            "message": "\"タイトル\" is not a valid Phone Number."
          }
        ]
      }
    }
  ]
}

「タイトル」という文字列はInvaildだという例外が得られました。

正しい文字をargumentとして渡した場合を見てみます。

{
  "data": {
    "updateUserInfo": {
      "userInfo": {
        "phoneNumber": "09012345678"
      }
    }
  }
}

例外が起こらずに処理が完了しました。


まとめ

CustomScalarを使えば、プリミティブな型(StringやInt等)を使うよりもより厳密にデータを扱うことができそうです。