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等)を使うよりもより厳密にデータを扱うことができそうです。

punditのpolicy_classで別モデルのPolicyを実行する

こんにちは!kossyです!



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
pundit 2.1.1




なお、Userモデル、Postモデル、Commentモデルが存在するものとします。

policy_classの使い方

例えば、CommentクラスのインスタンスにPostモデルのPolicyを実行したいケースです。

def show
  @comment = Comment.find(params[:id])

  authorize(@comment, policy_class: PostPolicy)
end

このように記述すると、PostPolicyが実行されるようになります。

class PostPolicy < ApplicationPolicy

  def show?
    user.admin?
  end

end

authorizeメソッドの引数にはsymbolで実行したいPolicyのメソッド名も指定することができます。

# PostPolicyクラスのindex?メソッドを実行する

authorize(@comment, :index?, policy_class: PostPolicy)

authorizeメソッドのコードを追ってみる

ではauthorizeメソッドのコードを追ってみて、どのように上記の挙動を実現しているのか確認してみましょう。

github.com

    # Retrieves the policy for the given record, initializing it with the
    # record and user and finally throwing an error if the user is not
    # authorized to perform the given action.
    #
    # @param user [Object] the user that initiated the action
    # @param record [Object] the object we're checking permissions of
    # @param query [Symbol, String] the predicate method to check on the policy (e.g. `:show?`)
    # @param policy_class [Class] the policy class we want to force use of
    # @raise [NotAuthorizedError] if the given query method returned false
    # @return [Object] Always returns the passed object record
    def authorize(user, record, query, policy_class: nil)
      policy = policy_class ? policy_class.new(user, record) : policy!(user, record)

      raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)

      record.is_a?(Array) ? record.last : record
    end

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

Retrieves the policy for the given record, initializing it with the record and user and finally throwing an error
if the user is not authorized to perform the given action.

指定されたレコードのポリシーを取得し、レコードとユーザーで初期化し、
ユーザーが指定されたアクションの実行を許可されていない場合は、最後にエラーをスローします。

@param user [Object] アクションを開始したユーザー
@param record [Object]パーミッションをチェックしているオブジェクト
@param query [Symbol、String]ポリシーをチェックするためのメソッド(例: show?)
@param policy_class [Class]強制的に使用するポリシークラス

指定されたクエリメソッドがfalseを返した場合は@raise [NotAuthorizedError]

@return [Object]常に渡されたオブジェクトレコードを返します

出典: pundit/pundit.rb at master · varvet/pundit · GitHub

引数と返り値についての記載がありました。

メソッドの中身を見ます。

policy = policy_class ? policy_class.new(user, record) : policy!(user, record)

この部分は、引数のpolicy_classが存在する場合、そのpolicy_classをuserとrecordを引数にしてイニシャライズしています。
policy_classがない場合はpolicy!メソッドを実行していますので、処理を見に行ってみます。

policy!

    # Retrieves the policy for the given record.
    #
    # @see https://github.com/varvet/pundit#policies
    # @param user [Object] the user that initiated the action
    # @param record [Object] the object we're retrieving the policy for
    # @raise [NotDefinedError] if the policy cannot be found
    # @raise [InvalidConstructorError] if the policy constructor called incorrectly
    # @return [Object] instance of policy class with query methods
    def policy!(user, record)
      policy = PolicyFinder.new(record).policy!
      policy.new(user, pundit_model(record))
    rescue ArgumentError
      raise InvalidConstructorError, "Invalid #<#{policy}> constructor is called"
    end

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

Retrieves the policy for the given record.

指定されたレコードのポリシーを取得します。

@param user [Object] アクションを開始したユーザー
@param record [Object]ポリシーを取得するオブジェクト
ポリシーが見つからない場合は@raise [NotDefinedError]
ポリシーコンストラクターが誤って呼び出された場合は@raise [InvalidConstructorError]
@return [Object]クエリメソッドを使用したポリシークラスのインスタンス

出典: pundit/pundit.rb at master · varvet/pundit · GitHub

引数と返り値、例外が発生する条件について記載がありました。

処理を見てみます。

policy = PolicyFinder.new(record).policy!
policy.new(user, pundit_model(record))

PolicyFinderインスタンスのpolicy!メソッドを見る必要がありそう。


def initialize(object)
  @object = object
end

# @return [Class] policy class with query methods
# @raise [NotDefinedError] if policy could not be determined
#
def policy!
  policy or raise NotDefinedError, "unable to find policy `#{find(object)}` for `#{object.inspect}`"
end

def policy
  klass = find(object)
  klass.is_a?(String) ? klass.safe_constantize : klass
end

def find(subject)
  if subject.is_a?(Array)
    modules = subject.dup
    last = modules.pop
    context = modules.map { |x| find_class_name(x) }.join("::")
    [context, find(last)].join("::")
  elsif subject.respond_to?(:policy_class)
    subject.policy_class
  elsif subject.class.respond_to?(:policy_class)
    subject.class.policy_class
  else
    klass = find_class_name(subject)
    "#{klass}#{SUFFIX}"
  end
end

def find_class_name(subject)
  if subject.respond_to?(:model_name)
    subject.model_name
  elsif subject.class.respond_to?(:model_name)
    subject.class.model_name
  elsif subject.is_a?(Class)
    subject
  elsif subject.is_a?(Symbol)
    subject.to_s.camelize
  else
    subject.class
  end
end

findメソッドかfind_class_nameメソッドでPolicyを実行するクラスを返して、返されたモデル名がString型ならsafe_constantizeメソッドでクラスとして扱えるようにし、
String型でなければPolicyを実行するクラスをそのまま返却しています。

つまり、PolicyFinderクラスのインスタンスメソッドであるpolicy!メソッドの返り値はPolicyを実行するクラスが返ることになります。

だいぶコードを追ったので改めてPunditモジュールのpolicy!メソッドの処理を見ます。

def policy!(user, record)
  policy = PolicyFinder.new(record).policy!
  policy.new(user, pundit_model(record))
rescue ArgumentError
  raise InvalidConstructorError, "Invalid #<#{policy}> constructor is called"
end

引数に誤りがあれば例外を投げて、とくに誤りがなければPolicyFinderインスタンスのpolicy!メソッドで見つけたクラスをnewして返却しています。


ようやくauthorizeメソッドに返ってくることができます、、、

def authorize(user, record, query, policy_class: nil)
  policy = policy_class ? policy_class.new(user, record) : policy!(user, record)

  raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)

  record.is_a?(Array) ? record.last : record
end

引数のpolicy_classが存在する場合、そのpolicy_classをuserとrecordを引数にしてイニシャライズしています。
policy_classが渡されていない場合は、recordのクラスのPolicyインスタンスを返却しています。

public_sendの返り値がnilまたはfalseの場合はNotAuthorizedErrorをthrowしています。

recordが配列ならrecordの最後のインスタンスを、配列でなければrecordをそのまま返します。

余談

authorizeの返り値がobjectなので、

@post = authorize Post.find(params[:id])

のような使い方ができるんですね。

github.com


感想

policy_classを用いればかなり柔軟にPolicyを実行することができるので、エッジケースに対応できそうです。


大いに参考にさせていただいた記事

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

pundit/README.md at master · varvet/pundit · GitHub

SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみる(Metadata編)

こんにちは!kossyです!



今回は、SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみた(SamlResponseだけ)ので、
備忘録としてブログに残してみたいと思います。





環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina



なお、IdPにはLINE WORKSを用いていると仮定してコードリーディングしてみます。

そして、リーディング元のレポジトリは拙著で作成したアプリケーションとします。

kossy-web-engineer.hatenablog.com




metadataとは?

そもそもmetadataとはなんでしょうか。

SAML メタデータ・ファイルには、 SAML 2.0 プロトコル・メッセージ交換で使用できるさまざまな SAML 権限に関する情報が含まれています。
このメタデータは、 ID プロバイダー・エンドポイントと証明書を識別し、 SAML 2.0 メッセージ交換を保護します。

出典: SAML 2.0 メタデータ・ファイルの定義

SAMLパーティ間で設定情報を表現および共有するXMLスキーマを定義
SPがIdPを利用するための情報を記述して、IdPとSPの信頼関係を構築できる。
・エンティティのサポートされているSAMLバインディング
・運用上の役割(IDP、SPなど)
・識別子情報、サポートID属性
・および暗号化と署名のための鍵情報

出典: SAMLの仕様を読む。 - マイクロソフト系技術情報 Wiki

SAML認証では基本的にIdPにServiceProviderの情報を登録する必要があるのですが、この登録時にmetadata(= SP Issuer、EntityIDとも)を取得するエンドポイントを登録します。

LINE WORKSの場合だと以下の画像のように登録します。

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

より詳しいMetadataの仕様について拙著では解説しませんので、興味のある方は以下のPDFを参考にしてみてください。

https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf

metadataアクション

コードリーディングに移ります。

def metadata
  settings = Account.get_saml_settings(get_url_base)
  meta = OneLogin::RubySaml::Metadata.new
  render :xml => meta.generate(settings, true)
end

metadataアクション自体は3行のコードです。

まずはOneLogin::RubySaml::Metadataのinitializeメソッドを見に行ってみます。と思ったらinitializeメソッドが定義されてなかった、、、

なので、generateメソッドを読んでみます。


OneLogin::RubySaml::Metadata#generate

def generate(settings, pretty_print=false, valid_until=nil, cache_duration=nil)
  meta_doc = XMLSecurity::Document.new
  add_xml_declaration(meta_doc)
  root = add_root_element(meta_doc, settings, valid_until, cache_duration)
  sp_sso = add_sp_sso_element(root, settings)
  add_sp_certificates(sp_sso, settings)
  add_sp_service_elements(sp_sso, settings)
  add_extras(root, settings)
  embed_signature(meta_doc, settings)
  output_xml(meta_doc, pretty_print)
end

最終的な返り値はXMLかと思われますが、いろいろなメソッドが呼ばれてますので、一つ一つ見ていきます。


add_xml_declaration(meta_doc)

def add_xml_declaration(meta_doc)
  meta_doc << REXML::XMLDecl.new('1.0', 'UTF-8')
end

引数のmeta_docにXMLDeclarationを追加するメソッドでした。

# rails c

$ meta_doc = XMLSecurity::Document.new

$ meta_doc << REXML::XMLDecl.new('1.0', 'UTF-8')
=>  <UNDEFINED> ... </>

www.weblio.jp


add_root_element

def add_root_element(meta_doc, settings, valid_until, cache_duration)
  namespaces = {
      "xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
  }

  if settings.attribute_consuming_service.configured?
    namespaces["xmlns:saml"] = "urn:oasis:names:tc:SAML:2.0:assertion"
  end

  root = meta_doc.add_element("md:EntityDescriptor", namespaces)
  root.attributes["ID"] = OneLogin::RubySaml::Utils.uuid
  root.attributes["entityID"] = settings.sp_entity_id if settings.sp_entity_id
  root.attributes["validUntil"] = valid_until.strftime('%Y-%m-%dT%H:%M:%S%z') if valid_until
  root.attributes["cacheDuration"] = "PT" + cache_duration.to_s + "S" if cache_duration
  root
end

SAMLに必要なRootElementを付与するメソッドでした。


add_sp_sso_element

def add_sp_sso_element(root, settings)
  root.add_element "md:SPSSODescriptor", {
      "protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol",
      "AuthnRequestsSigned" => settings.security[:authn_requests_signed],
      "WantAssertionsSigned" => settings.security[:want_assertions_signed],
  }
end

SPSSODescriptorの説明については以下ブログが参考になりました。

blog.cybozu.io


AuthnRequestsSignedについては以下の記事が参考になりました。

AuthnRequestsSigned

このサービス・プロバイダーによって送信される <samlp:AuthnRequest> メッセージに署名するかどうかを指定します。

出典: SAML Web SSO 2.0 認証 (samlWebSso20)

この変更が最初に加わったのはこのPRのようです。(当初はDefaultでfalseで外部からカスタマイズができない設定だったんですね。)

github.com



wantAssertionsSignedについては以下の記述。

wantAssertionsSigned

このサービス・プロバイダーが受信する <saml:Assertion> エレメントが、アサーションに署名する Signature エレメントを含む必要があるかを指定します。

出典: SAML Web SSO 2.0 認証 (samlWebSso20)

add_sp_certificates

# Add KeyDescriptor if messages will be signed / encrypted
# with SP certificate, and new SP certificate if any

# メッセージがSP証明書で署名/暗号化される場合はKeyDescriptorを追加し、
# 存在する場合は新しいSP証明書を追加します

def add_sp_certificates(sp_sso, settings)
  cert = settings.get_sp_cert
  cert_new = settings.get_sp_cert_new

  for sp_cert in [cert, cert_new]
    if sp_cert
      cert_text = Base64.encode64(sp_cert.to_der).gsub("\n", '')
      kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
      ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
      xd = ki.add_element "ds:X509Data"
      xc = xd.add_element "ds:X509Certificate"
      xc.text = cert_text

      if settings.security[:want_assertions_encrypted]
        kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
        ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
        xd2 = ki2.add_element "ds:X509Data"
        xc2 = xd2.add_element "ds:X509Certificate"
        xc2.text = cert_text
      end
    end
  end

  sp_sso
end

正直なんのこっちゃなのでコンソールで実行してみます。

$ cert = settings.get_sp_cert
=> nil

$ cert_new = settings.get_sp_cert_new
=> nil

$   for sp_cert in [cert, cert_new]
    if sp_cert
      cert_text = Base64.encode64(sp_cert.to_der).gsub("\n", '')
      kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
      ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
      xd = ki.add_element "ds:X509Data"
      xc = xd.add_element "ds:X509Certificate"
      xc.text = cert_text

      if settings.security[:want_assertions_encrypted]
        kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
        ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
        xd2 = ki2.add_element "ds:X509Data"
        xc2 = xd2.add_element "ds:X509Certificate"
        xc2.text = cert_text
      end
    end
  end

=> [nil, nil]

私の環境ではcertもcert_newもnilでした。

理由はsettingsオブジェクトのcertificate属性に値を入れていないからでした。


add_sp_service_elements

def add_sp_service_elements(sp_sso, settings)
  if settings.single_logout_service_url
    sp_sso.add_element "md:SingleLogoutService", {
        "Binding" => settings.single_logout_service_binding,
        "Location" => settings.single_logout_service_url,
        "ResponseLocation" => settings.single_logout_service_url
    }
  end

  if settings.name_identifier_format
    nameid = sp_sso.add_element "md:NameIDFormat"
    nameid.text = settings.name_identifier_format
  end

  if settings.assertion_consumer_service_url
    sp_sso.add_element "md:AssertionConsumerService", {
        "Binding" => settings.assertion_consumer_service_binding,
        "Location" => settings.assertion_consumer_service_url,
        "isDefault" => true,
        "index" => 0
    }
  end

  if settings.attribute_consuming_service.configured?
    sp_acs = sp_sso.add_element "md:AttributeConsumingService", {
      "isDefault" => "true",
      "index" => settings.attribute_consuming_service.index
    }
    srv_name = sp_acs.add_element "md:ServiceName", {
      "xml:lang" => "en"
    }
    srv_name.text = settings.attribute_consuming_service.name
    settings.attribute_consuming_service.attributes.each do |attribute|
      sp_req_attr = sp_acs.add_element "md:RequestedAttribute", {
        "NameFormat" => attribute[:name_format],
        "Name" => attribute[:name],
        "FriendlyName" => attribute[:friendly_name],
        "isRequired" => attribute[:is_required] || false
      }
      unless attribute[:attribute_value].nil?
        Array(attribute[:attribute_value]).each do |value|
          sp_attr_val = sp_req_attr.add_element "saml:AttributeValue"
          sp_attr_val.text = value.to_s
        end
      end
    end
  end

  # With OpenSSO, it might be required to also include
  #  <md:RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:query="urn:oasis:names:tc:SAML:metadata:ext:query" xsi:type="query:AttributeQueryDescriptorType" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
  #  <md:XACMLAuthzDecisionQueryDescriptor WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>

  sp_sso
end

ServiceProviderの情報をXMLに適宜追加するメソッドですね。


add_extras

# can be overridden in subclass
def add_extras(root, _settings)
  root
end

サブクラスでオーバーライドできるとのことでした。

このメソッドが加わったのは以下のPRです。

github.com

Conversationを見ると独自で追加したいメタデータを定義する用途で使うメソッドっぽいです。


embed_signature

def embed_signature(meta_doc, settings)
  return unless settings.security[:metadata_signed]

  private_key = settings.get_sp_key
  cert = settings.get_sp_cert
  return unless private_key && cert

  meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
end


XMLに署名を埋め込むメソッドですね。こちらはmetadata_signed属性やsp_key、certificate等がないと処理がスキップされるみたいです。


最後のoutput_xmlは引数を元にXMLを出力するメソッドでした。


まとめ

metadataの仕様が分からなさすぎて、ただ処理を追うだけになってしまい「なんでこの処理が必要なのか」までは理解ができませんでした。。。

次はmetadataの仕様を自分なりに調べてまとめた記事になりそう、、、


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

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

SAML認証ができるまで - Cybozu Inside Out | サイボウズエンジニアのブログ
SAML Web SSO 2.0 認証 (samlWebSso20)

RailsのActiveRecordにおいて保存していない親レコードに紐づく子レコードへのpluckは空配列が返る件

こんにちは!kossyです!




今回は保存していない親レコードに紐づく子レコードにpluckメソッドを使って、意図通り動作しない事象に遭遇したため、

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



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur



pluckメソッド

Railsでは、ActiveRecordのpluckメソッドとActiveSupportを用いている場合にEnumerableにpluckメソッドが定義されています。

api.rubyonrails.org

edgeguides.rubyonrails.org

ActiveRecordのpluckメソッドは実行時にSQLが走り、返り値は配列が返ります。(これを理解していればハマることはなかった。)


ハマったポイント

以下のようなケースでハマりました。

$ user = User.new(name: '田中太郎', email: "tarotanaka@gmail.com")

$ Post.new(user: user, title: "田中性は日本に何人いるのか?", body: "続きはWebで")

$ user.posts.pluck(:title)
=> []

とある処理の中で親インスタンスと子インスタンスを生成し、紐づく子インスタンスの値をまとめて取得して後続の処理を行いたいケースがありました。

pluckを使えば子インスタンスの値を取得できると思っていたのですが、前述の通り、ActiveRecordのpluckメソッドの場合は実行時にSQLが走りますので、

まだDBに保存されていないレコードを対象に実行しても返り値は空の配列になります。

なので、今回のケースではEnumerable#mapメソッドを使うのが正解でした。

$ user = User.new(name: '田中太郎', email: "tarotanaka@gmail.com")

$ Post.new(user: user, title: "田中性は日本に何人いるのか?", body: "続きはWebで")

$ user.posts.map(&:title)
=> ["田中性は日本に何人いるのか?"]

まとめ

ActiveRecordのpluckメソッドの挙動を理解していればハマらなかっただろうに、使ってしまった時間が悔やまれます。

この記事がどなたかの助けになることを願っています。。。



大いに参考にさせていただいた記事

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

Railsの技: pluckはActive RecordモデルでもEnumerableでも使える(翻訳)|TechRacho by BPS株式会社

SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみる(SamlResponse編)

こんにちは!kossyです!



今回は、SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみた(SamlResponseだけ)ので、
備忘録としてブログに残してみたいと思います。





環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina



なお、IdPにはLINE WORKSを用いていると仮定してコードリーディングしてみます。

そして、リーディング元のレポジトリは拙著で作成したアプリケーションとします。

kossy-web-engineer.hatenablog.com

acsアクション

SAMLRequestを送った後、IdP側からacsアクションが叩かれます。

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

OneLogin::RubySaml::Responseクラスのinitializeメソッドから読んでいきます。


OneLogin::RubySaml::Response#initialize

def initialize(response, options = {})
  raise ArgumentError.new("Response cannot be nil") if response.nil?

  @errors = []

  @options = options
  @soft = true
  unless options[:settings].nil?
    @settings = options[:settings]
    unless @settings.soft.nil?
      @soft = @settings.soft
    end
  end

  @response = decode_raw_saml(response, settings)
  @document = XMLSecurity::SignedDocument.new(@response, @errors)

  if assertion_encrypted?
    @decrypted_document = generate_decrypted_document
  end
end

まず、response(= params[:SAMLResponse])がnilの場合は例外を発生させて処理を終了させています。

次に、options[:settings](= saml_settings)がnilでなければ、@settingsとしてインスタンス変数化し、さらに@settingsのsoft属性がnilでなければ、@settings.softの値を@softとして定義しています。

decode_raw_samlメソッドを見てみます。

decode_raw_saml

# https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/saml_message.rb

def decode_raw_saml(saml, settings = nil)
  return saml unless base64_encoded?(saml)

  settings = OneLogin::RubySaml::Settings.new if settings.nil?
  if saml.bytesize > settings.message_max_bytesize
    raise ValidationError.new("Encoded SAML Message exceeds " + settings.message_max_bytesize.to_s + " bytes, so was rejected")
  end

  decoded = decode(saml)
  begin
    inflate(decoded)
  rescue
    decoded
  end
end

# base64_encoded?メソッドの定義はこちら
# BASE64_FORMAT = %r(\A([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z)
def base64_encoded?(string)
  !!string.gsub(/[\r\n]|\\r|\\n|\s/, "").match(BASE64_FORMAT)
end

# decodeメソッドの定義はこちら
def decode(string)
  Base64.decode64(string)
end

# inflateメソッドの定義はこちら
def inflate(deflated)
  Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(deflated)
end

早期リターンを行なっている行は、引数のsaml(= params[:SAMLResponse])がbase64エンコードできる文字だけで構成されているかを確認しています。

settingsがnilの場合は新規でOneLogin::RubySaml::Settingsインスタンスを生成していますね。

次の行のif文では、samlのbytesizeの方がsettingsのmessage_max_bytesize属性よりも大きかった場合に、例外を発生させています。

このバリデーションが追加された経緯はこちらのPRのようでした。

github.com

samlのbytesizeを巨大にすることでDos攻撃が可能になっていたため、上記のバリデーションが追加されたようです。

また、元々はOneLogin::RubySaml::SamlMessageクラスに定数として定義されていたバリデーションの数値が、OneLogin::RubySaml::Settingsインスタンスから参照可能なように書き換えられたようでした。

github.com



次はassertion_encrypted?メソッドを見てみます。

assertion_encrypted?

# Checks if the SAML Response contains or not an EncryptedAssertion element
# @return [Boolean] True if the SAML Response contains an EncryptedAssertion element

# SAMLResponse に EncryptedAssertion 要素が含まれているかどうかを確認します
# SAMLResponse に EncryptedAssertion要素が含まれている場合はTrueが返り値となります

def assertion_encrypted?
  ! REXML::XPath.first(
    document,
    "(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
    { "p" => PROTOCOL, "a" => ASSERTION }
  ).nil?
end

コメントアウト部分を読めばOKなコードでした。

if assertion_encrypted?
  @decrypted_document = generate_decrypted_document
end

この箇所は、「SAMLResponse に EncryptedAssertion要素が含まれている場合は、複合化したDocumentを生成する」と読めそうです。(generate_decrypted_documentのコードリーディングは割愛)



やっとinitializeメソッドを読み終えました。。。


OneLogin::RubySaml::Response#is_valid?

次はis_valid?メソッドを追ってみます。

# Validates the SAML Response with the default values (soft = true)
# @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
#

# SAMLResponseをデフォルト値で検証します(soft = true)
# 最初のエラーが表示されたら検証を停止するか、検証を続けます。 (soft = trueの場合)
# SAMLResponseが有効な場合は TRUE が返り値となります。

def is_valid?(collect_errors = false)
  validate(collect_errors)
end

# validateメソッドの定義はこちら

# 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

# SAMLResponseを検証します(いくつかの検証メソッドを呼び出します)
# 最初のエラーが表示されたら検証を停止するか、検証を続けます。 (soft = trueの場合)
# SAMLResponseが有効な場合はTrue、それ以外の場合はsoft = Trueの場合はFalse
# soft == falseで、検証が失敗した場合はValidationErrorを発生させます。

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

Input/Outputはわかりましたが、内部で呼び出しているバリデーションメソッドを全て読むのは流石に骨が折れるので、いくつか気になったメソッドをピックアップして読んでいきます。


validate_issuer

def validate_issuer
  return true if settings.idp_entity_id.nil?

  begin
    obtained_issuers = issuers
  rescue ValidationError => e
    return append_error(e.message)
  end

  obtained_issuers.each do |issuer|
    unless OneLogin::RubySaml::Utils.uri_match?(issuer, settings.idp_entity_id)
      error_msg = "Doesn't match the issuer, expected: <#{settings.idp_entity_id}>, but was: <#{issuer}>"
      return append_error(error_msg)
    end
  end

  true
end

# issuersメソッドの定義はこちら

# Gets the Issuers (from Response and Assertion).
# (returns the first node that matches the supplied xpath from the Response and from the Assertion)
# @return [Array] Array with the Issuers (REXML::Element)

# issuerを取得します(SamlResponseとアサーションから)。
# SAMLResponseおよびアサーションから指定されたxpathに一致する最初のノードを返します
# 返り値はissuersの配列が返ります

def issuers
  @issuers ||= begin
    issuer_response_nodes = REXML::XPath.match(
      document,
      "/p:Response/a:Issuer",
      { "p" => PROTOCOL, "a" => ASSERTION }
    )

    unless issuer_response_nodes.size == 1
      error_msg = "Issuer of the Response not found or multiple."
      raise ValidationError.new(error_msg)
    end

    issuer_assertion_nodes = xpath_from_signed_assertion("/a:Issuer")
    unless issuer_assertion_nodes.size == 1
      error_msg = "Issuer of the Assertion not found or multiple."
      raise ValidationError.new(error_msg)
    end

    nodes = issuer_response_nodes + issuer_assertion_nodes
    nodes.map { |node| Utils.element_text(node) }.compact.uniq
  end
end

begin end と ||= を使ってSAMLのissuerを見ながらバリデーションを行い、問題なければ@issuersをインスタンス変数で定義するメソッドでした。

settingsのissuerとSAMLに記載されたissuerがマッチしない場合は、append_errorメソッドを呼び出していました。


validate_name_id

# Validates the NameID element
def validate_name_id
  if name_id_node.nil?
    if settings.security[:want_name_id]
      return append_error("No NameID element found in the assertion of the Response")
    end
  else
    if name_id.nil? || name_id.empty?
      return append_error("An empty NameID value found")
    end

    unless settings.sp_entity_id.nil? || settings.sp_entity_id.empty? || name_id_spnamequalifier.nil? || name_id_spnamequalifier.empty?
      if name_id_spnamequalifier != settings.sp_entity_id
        return append_error("The SPNameQualifier value mistmatch the SP entityID value.")
      end
    end
  end

  true
end

# name_id_nodeメソッドの定義はこちら
def name_id_node
  @name_id_node ||=
    begin
      encrypted_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID')
      if encrypted_node
        node = decrypt_nameid(encrypted_node)
      else
        node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
      end
    end
end

もしname_id_nodeがnilで、settings.security[:want_name_id]が存在する場合、append_errorを呼び出しています。

want_name_idが追加されたcommitはこちらです。

github.com

commitメッセージを見たところ、「Several security improvements: Reject empty nameID」との文言があったため、NameIDが空でも処理が成功してしまう脆弱性を改善したんだと思われます。



以上、全てのバリデーションを実行してエラーがなければ、validとみなされます。


まとめ

残りの処理はsessionにnameidを格納したりログに「ログイン成功!」「nameidはこちら!」と書き込んで、indexアクションをレンダリングするだけです。

session[:nameid] = response.nameid
session[:attributes] = response.attributes
@attrs = session[:attributes]
logger.info "Sucessfully logged"
logger.info "NAMEID: #{response.nameid}"
render :action => :index

実際にコードを読んでみて思ったことですが、PRの概要やcommitメッセージを読むと、多岐に渡る考慮が追加されているのがわかります。

特にsamlのbytesizeの方がsettingsのmessage_max_bytesize属性よりも大きかった場合に、

例外を発生させる実装についての背景は今後のプログラミングにもすぐに活かせそうだと感じています。(Dos攻撃のくだりですね)



あとがき

正直validationのところだけで1記事書けるんじゃないかと思っているので、また別の機会に書いてみようと思います。。。



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

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

ruby-saml を使ったOneLogin での SAML 認証の処理シーケンスを追う - Qiita

SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみる(SamlRequest編)

こんにちは!kossyです!



今回は、SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみた(SamlRequestだけ)ので、
備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina





なお、IdPにはLINE WORKSを用いていると仮定してコードリーディングしてみます。

そして、リーディング元のレポジトリは拙著で作成したアプリケーションとします。

kossy-web-engineer.hatenablog.com


sso アクション

まずはSAMLリクエストを送信するアクションのソースコードから読んでみます。

class SamlController < ApplicationController
  # 省略

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

    request = OneLogin::RubySaml::Authrequest.new

    redirect_to(request.create(settings))
  end

  # 省略
end

requestオブジェクトのcreateメソッドの処理を見てみます。

def create(settings, params = {})
  params = create_params(settings, params)
  params_prefix = (settings.idp_sso_service_url =~ /\?/) ? '&' : '?'
  saml_request = CGI.escape(params.delete("SAMLRequest"))
  request_params = "#{params_prefix}SAMLRequest=#{saml_request}"
  params.each_pair do |key, value|
    request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
  end
  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?
  @login_url = settings.idp_sso_service_url + request_params
end


create_paramsが実際にパラメータを生成する処理っぽいので見てみます。

def create_params(settings, params={})
  # The method expects :RelayState but sometimes we get 'RelayState' instead.
  # Based on the HashWithIndifferentAccess value in Rails we could experience
  # conflicts so this line will solve them.
  relay_state = params[:RelayState] || params['RelayState']

  if relay_state.nil?
    params.delete(:RelayState)
    params.delete('RelayState')
  end

  request_doc = create_authentication_xml_doc(settings)
  request_doc.context[:attribute_quote] = :quote if settings.double_quote_xml_attribute_values

  request = ""
  request_doc.write(request)

  Logging.debug "Created AuthnRequest: #{request}"

  request = deflate(request) if settings.compress_request
  base64_request = encode(request)
  request_params = {"SAMLRequest" => base64_request}

  if settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && settings.private_key
    params['SigAlg']    = settings.security[:signature_method]
    url_string = OneLogin::RubySaml::Utils.build_query(
      :type => 'SAMLRequest',
      :data => base64_request,
      :relay_state => relay_state,
      :sig_alg => params['SigAlg']
    )
    sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method])
    signature = settings.get_sp_key.sign(sign_algorithm.new, url_string)
    params['Signature'] = encode(signature)
  end

  params.each_pair do |key, value|
    request_params[key] = value.to_s
  end

  request_params
end

長い、、、じっくり見てみます。まずはコメントアウトの翻訳から。

create_params(settings, params={})

The method expects :RelayState but sometimes we get 'RelayState' instead.
Based on the HashWithIndifferentAccess value in Rails we could experience
conflicts so this line will solve them.

このメソッドは:RelayStateを想定していますが、代わりに 'RelayState' を取得する場合があります。
RailsのHashWithIndifferentAccess値に基づいて、競合が発生する可能性があるため、この行で競合を解決します。

出典: https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/authrequest.rb#L54

シンボルでも文字列でのkey指定でもRelayState(検証後の遷移先)を取得できるようにしているようです。

もしrelay_stateの値がnilならparamsからRelayStateを削除しています。

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

def create_authentication_xml_doc(settings)
  document = create_xml_document(settings)
  sign_document(document, settings)
end

create_xml_documentメソッド、長いぞ、、、

create_xml_document

def create_xml_document(settings)
  time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")

  request_doc = XMLSecurity::Document.new
  request_doc.uuid = uuid

  root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
  root.attributes['ID'] = uuid
  root.attributes['IssueInstant'] = time
  root.attributes['Version'] = "2.0"
  root.attributes['Destination'] = settings.idp_sso_service_url unless settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
  root.attributes['IsPassive'] = settings.passive unless settings.passive.nil?
  root.attributes['ProtocolBinding'] = settings.protocol_binding unless settings.protocol_binding.nil?
  root.attributes["AttributeConsumingServiceIndex"] = settings.attributes_index unless settings.attributes_index.nil?
  root.attributes['ForceAuthn'] = settings.force_authn unless settings.force_authn.nil?

  # Conditionally defined elements based on settings
  if settings.assertion_consumer_service_url != nil
    root.attributes["AssertionConsumerServiceURL"] = settings.assertion_consumer_service_url
  end
  if settings.sp_entity_id != nil
    issuer = root.add_element "saml:Issuer"
    issuer.text = settings.sp_entity_id
  end

  if settings.name_identifier_value_requested != nil
    subject = root.add_element "saml:Subject"

    nameid = subject.add_element "saml:NameID"
    nameid.attributes['Format'] = settings.name_identifier_format if settings.name_identifier_format
    nameid.text = settings.name_identifier_value_requested

    subject_confirmation = subject.add_element "saml:SubjectConfirmation"
    subject_confirmation.attributes['Method'] = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
  end

  if settings.name_identifier_format != nil
    root.add_element "samlp:NameIDPolicy", {
        # Might want to make AllowCreate a setting?
        "AllowCreate" => "true",
        "Format" => settings.name_identifier_format
    }
  end

  if settings.authn_context || settings.authn_context_decl_ref

    if settings.authn_context_comparison != nil
      comparison = settings.authn_context_comparison
    else
      comparison = 'exact'
    end

    requested_context = root.add_element "samlp:RequestedAuthnContext", {
      "Comparison" => comparison,
    }

    if settings.authn_context != nil
      authn_contexts_class_ref = settings.authn_context.is_a?(Array) ? settings.authn_context : [settings.authn_context]
      authn_contexts_class_ref.each do |authn_context_class_ref|
        class_ref = requested_context.add_element "saml:AuthnContextClassRef"
        class_ref.text = authn_context_class_ref
      end
    end

    if settings.authn_context_decl_ref != nil
      authn_contexts_decl_refs = settings.authn_context_decl_ref.is_a?(Array) ? settings.authn_context_decl_ref : [settings.authn_context_decl_ref]
      authn_contexts_decl_refs.each do |authn_context_decl_ref|
        decl_ref = requested_context.add_element "saml:AuthnContextDeclRef"
        decl_ref.text = authn_context_decl_ref
      end
    end
  end

  request_doc
end

長いですが、SAMLのキモであるXMLドキュメントの構築部分なので気合いいれて読んで試してみます。

# rails c

$ settings = Account.get_saml_settings("http://localhost:3000")

$ request = OneLogin::RubySaml::Authrequest.new

$ time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
=> "2021-10-23T12:45:30Z"

$ request_doc = XMLSecurity::Document.new
=> <UNDEFINED/>

# uuidはSecureRandom.uuid
# OneLogin::RubySaml::Utils.uuid
# def self.uuid
#   RUBY_VERSION < '1.9' ? "_#{@@uuid_generator.generate}" : "_#{SecureRandom.uuid}"
# end

$  request_doc.uuid = request.uuid 
=> "_2179fe0b-198a-46ab-83da-a155128f8080"

$  root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
=> <samlp:AuthnRequest xmlns:samlp='urn:oasis:names:tc:SAML:2.0:protocol' xmlns:saml='urn:oasis:names:tc:SAML:2.0:assertion'/>

$  root.attributes['ID'] = request.uuid # 認証要求メッセージ毎にユニークなランダム文字列
=> "_2179fe0b-198a-46ab-83da-a155128f8080"

$ root.attributes['IssueInstant'] = time # 認証要求メッセージの発行日時
=> "2021-10-23T12:45:30Z"

$ root.attributes['Version'] = "2.0" # SAMLのバージョン
=> "2.0"

# root.attributes['Destination'] = settings.idp_sso_service_url unless settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
# をまるっと実行するのは面白くないので1フレーズ毎に実行してみます。

$ settings.idp_sso_service_url
=> "https://auth.worksmobile.com/saml2/idp/your_group_name"

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

# Destinationは認証要求先
$ root.attributes['Destination'] = settings.idp_sso_service_url unless settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
=> "https://auth.worksmobile.com/saml2/idp/your_group_name"

$ root.attributes['IsPassive'] = settings.passive unless settings.passive.nil? # ユーザーを関与させずに認証できる場合にのみこのユーザーを認証するかどうか
=> nil

# IdP側がSPにSamlResponseを送信する際に利用するSAML Binding
$ root.attributes['ProtocolBinding'] = settings.protocol_binding unless settings.protocol_binding.nil?
=> "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"

# IdPに要求するユーザ属性グループのインデックスを指定
$ root.attributes["AttributeConsumingServiceIndex"] = settings.attributes_index unless settings.attributes_index.nil?

# IdPが以前のSecurity Contextを使用せず、必ずユーザーを直接的に認証するかどうかを指定
$ root.attributes['ForceAuthn'] = settings.force_authn unless settings.force_authn.nil?

$ settings.assertion_consumer_service_url != nil
=> true

# SPがSamlResponseを受け取るエンドポイントのURL
$ root.attributes["AssertionConsumerServiceURL"] = settings.assertion_consumer_service_url
=> "http://localhost:3000/saml/acs"

$ settings.sp_entity_id != nil
=> true

$ issuer = root.add_element "saml:Issuer" # SPのユニークなID
=> <saml:Issuer/>

$ issuer.text = settings.sp_entity_id
=> "http://localhost:3000/saml/metadata"

$ settings.name_identifier_value_requested != nil
=> false

$ settings.name_identifier_format != nil
=> true

# SamlResponseメッセージ内のユーザーの識別子に関するポリシー
$ root.add_element "samlp:NameIDPolicy", {
    # Might want to make AllowCreate a setting?
    "AllowCreate" => "true",
    "Format" => settings.name_identifier_format
}

# 電子メール アドレス形式で NameID 要求を発行
$ <samlp:NameIDPolicy AllowCreate='true' Format='urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'/>

# authn_contextは必要な認証方法を指定
$ settings.authn_context || settings.authn_context_decl_ref
=> nil

これで一通りcreate_xml_documentメソッドの処理が追えました、、、

次にsign_documentメソッドを見てみます。

sign_document

def sign_document(document, settings)
  if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && settings.private_key && settings.certificate
    private_key = settings.get_sp_key
    cert = settings.get_sp_cert
    document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
  end

  document
end

こちらもコンソールで実行しながら試してみます。

$ settings.idp_sso_service_binding
=> "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"

$ OneLogin::RubySaml::Utils::BINDINGS[:post]
=> "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"

$ settings.security[:authn_requests_signed]
=> false

$ settings.private_key && settings.certificate
=> nil

$ settings.idp_sso_service_binding == OneLogin::RubySaml::Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && settings.private_key && settings.certificate
=> false

もし上記の条件がtrueだった場合は、XMLSecurity::Documentのインスタンスに定義されているメソッドであるsign_documentを呼び出す実装になっていました。

create_authentication_xml_docメソッド内で呼び出されているメソッドを読み終えたので、create_paramsメソッドに戻ります。

create_params

# 一部のIdPでSPで開始されたシナリオでの属性値のシングルクォートに問題があるため
$ request_doc.context[:attribute_quote] = :quote if settings.double_quote_xml_attribute_values
=> nil

$ request = ""

$ request_doc.write(request)
=> [<?xml ... ?>,
 <samlp:AuthnRequest xmlns:samlp='urn:oasis:names:tc:SAML:2.0:protocol' xmlns:saml='urn:oasis:names:tc:SAML:2.0:assertion' IssueInstant='2021-10-23T12:45:30Z' Version='2.0' ID='_2179fe0b-198a-46ab-83da-a155128f8080' Destination='https://auth.worksmobile.com/saml2/idp/your_group_name' AssertionConsumerServiceURL='http://localhost:3000/saml/acs'> ... </>]

# FirefoxSAMLTracerなどの診断ツールとうまく連携するため
# https://github.com/onelogin/ruby-saml/pull/57
$ request = Zlib::Deflate.deflate(request, 9)[2..-5] if settings.compress_request
=> "}\x92MK\xC3@\x10\x86\xFFJn{\xCA\xC7\xA6\xAD\xC6%\t\x84\x16\xA1PEZ\xF5\xE0E\xA6\xC9\x94,\xD9\x8F\xB8\xB3\xB1\xFA\xEFMS\x84z\xB0\xD7\xE5y\xDEy\x99\xD9\x9C@\xAB^T\x83o\xCD\x16?\x06$\x1FTD\xD8\xBC\xB4fi\r\r\x1A\xDD\x0E\xDD\xA7\xAC\xF1e\xBB)X\xEB}/\xE2X\xD9\x1ATk\xC9\x8BY\x92$\xF1)%\x86\x9AX\xB0\x1A\x13\xA4\x81\x93~\x86i\xA4a\x8C\x8F\x8E\xD6u\xA4\xED^*\x8Cj\xAB''\x8De\xD3\xC7\x1DJ\x1A:\xCC,\xA5\xB2\x83#\xB0`\xBD*\xD8{\xCAo\xEF\x0E\x98\xECC~\x97A8\xBF\x81}\x98\xCD\x1A\b\x81/\x16<\xCD\x0EY\x92%#J4\xE0\xDA\x90\a\xE3\v\x96&)\x0Fy\x12\xA6\xB3g\x9E\x8A\xF9b\xAC\xF7\xC6\x82Wt4\x15J\xA3\xD1\xF8\xD2\xCA\x908\x8D/\xD8\xE0\x8C\xB0@\x92\x84\x01\x8D$|-v\xD5\xC3F\x8C\xB0\x80\xDF=\\*\xFDu\xA7w\xD6\xDB\xDA*V\xE6'ZL\xED\\yek\x1A=4\xE0!\x8F/\x85\xFC|\x97\xC7q\xC0z\xF5d\x95\xAC\xBF\x83J){\\:\x04\x8F\x05\xF3n@\x16\xDC[\xA7\xC1\xFF_\x89G|z\x91Mx\x98P\x81\x1A\xA4\xAA\x9A\xC6!\x11\x8B\xCB\xF3\xD4\xBF\x1F\xA0\xFC\x01"

# https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/saml_message.rb#L128
$ base64_request = Base64.strict_encode64(request)
=> "fZJNS8NAEIb/ms54ysemrcYlCYQWoVBFWvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnECrXlSDb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN+hmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLWygyOwYL0q2HvKb+8OmOxDfpdBOL+BfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB/1+JR3x6kU14mFCBGqSqmsYhEYvL89S/H6D8AQ=="

$ request_params = {"SAMLRequest" => base64_request}

$ request_params = {"SAMLRequest" => base64_request}
=> {"SAMLRequest"=>"fZJNS8NAEIb/ms54ysemrcYlCYQWoVBFWvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnECrXlSDb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN+hmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLWygyOwYL0q2HvKb+8OmOxDfpdBOL+BfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB/1+JR3x6kU14mFCBGqSqmsYhEYvL89S/H6D8AQ=="}

$ settings.idp_sso_service_binding == OneLogin::RubySaml::Utils::BINDINGS[:redirect]
=> true

$ settings.security[:authn_requests_signed] && settings.private_key
=> false

$ settings.idp_sso_service_binding == OneLogin::RubySaml::Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && settings.private_key
=> false

$ params.each_pair do |key, value|
  request_params[key] = value.to_s
end
=> {}

ようやくcreate_paramsメソッドも読み終わりました。ざっくりの理解ですがSamlRequest用のパラメータを構築するための処理でしたね。

createメソッドの処理に戻ります。

create

$ params = request_params

$ params_prefix = (settings.idp_sso_service_url =~ /\?/) ? '&' : '?'
=> "?"

# SAMLRequest needs to be URL-encoded とのこと。
$ saml_request = CGI.escape(params.delete("SAMLRequest"))
=> "fQJNS8NADIb%2FSm57ysemrcYlCYQWoVBFDvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnDCrCVAQb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN%2BhmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLSygyOwMS0q2HvKb%2B8LmOxDfpdBOL%2BBfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB%2F1%2BJR3x6kU14mFCBGqSqmsYhEYvL89S%2FH6D8AQ%3D%3C"

$ request_params = "#{params_prefix}SAMLRequest=#{saml_request}"
=> "?fQJNS8NADIb%2FSm57ysemrcYlCYQWoVBFDvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnDCrCVAQb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN%2BhmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLSygyOwMS0q2HvKb%2B8LmOxDfpdBOL%2BBfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB%2F1%2BJR3x6kU14mFCBGqSqmsYhEYvL89S%2FH6D8AQ%3D%3C"

$ params.each_pair do |key, value|
  request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
end
=> {}

# もしidp_sso_service_urlがnilまたは空の場合は Invalid settings, idp_sso_service_url is not set! の例外が返る
$ settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
=> false

$ @login_url = settings.idp_sso_service_url + request_params
=> "https://auth.worksmobile.com/saml2/idp/your_group_name?SAMLRequest=fQJNS8NADIb%2FSm57ysemrcYlCYQWoVBFDvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnDCrCVAQb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN%2BhmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLSygyOwMS0q2HvKb%2B8LmOxDfpdBOL%2BBfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB%2F1%2BJR3x6kU14mFCBGqSqmsYhEYvL89S%2FH6D8AQ%3D%3C"

やっと読み終えた、、、

OneLogin::RubySaml::AuthRequestインスタンスのcreateメソッドは、SAMLRequest用のURLを返却するメソッドでした。(間でいろーんな処理を挟みつつ。)

もう一度controllerの処理を見ます。

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

  request = OneLogin::RubySaml::Authrequest.new

  redirect_to(request.create(settings))
end

ssoアクションsamlのsettingsがnilならno_settingsの画面をレンダリングし、settingsがあればrequest.create(settings)の処理で得られたURLへリダイレクトする処理でした。


まとめ

acsアクションのコードリーディングは別の記事にします。(すでに16000字越え)

SamlRequest生成のところだけでも知らない用語が大量にあり、読むのに大変時間がかかりました、、、

ただ、実行しつつわからない単語を調べつつ進めたことで、ただ「SSOできた!!」だけで終わらせるよりも遥かに効果があったと感じています。

SamlResponseの方も今回と同じくらいの文量になりそう、、、



ruby-saml を使ったSSOを本番環境で試してみる

こんにちは!kossyです!

さて、今回はSAML認証のクライアント側を実装できるGem「ruby-saml」でLINE WORKSのSAML2.0でSSOをHerokuにデプロイした環境で試してみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina




以前、レポジトリのセットアップからruby-samlの導入、ローカル環境での動作確認までを以下ブログで書いております。

今回の記事で以下で作成したレポジトリをherokuにデプロイしてSSOを試してみたいと思います。

kossy-web-engineer.hatenablog.com



herokuへのデプロイ

こちらのガイドを参考にしました。

devcenter.heroku.com

ガイドに記載されてない点で実行したことは環境変数の設定です。

$ heroku config:set IDP_GROUP_NAME=your_group_name

$ heroku config:set IDP_FINGERPRINT=38:5A:8C:53:CC:8A:C8:53:C3:32:C2:00:34:C1:A5:8A:00:23:0A:C8:0C:6A:36:CF:41:EA:42:5F:3E:62:07:85

これで環境変数ソースコード内で参照できるようになります。

LINE WORKSの設定

開発環境の設定とは別で新規で作成するのがいいでしょう。

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

herokuへのアプリケーションデプロイに成功するとURLが発行されると思いますので、そちらのURLをyour_domainの箇所と置き換えてください。

これで一通り動作が確認できる環境は整ったかと思います。


トラブルシューティング

heroku logs --tail コマンドを実行すると、起動中のアプリケーションのログをリアルタイムで確認することができます。

$ heroku logs --tail

私の場合、ACS URLが間違っていて Routing Errorが発生していたので、ログを確認することで間違いに気が付くことができました。。。

まとめ

動作確認はローカル環境の時とほとんど変わらない(URLが違うだけ)なので割愛します。

今回はデプロイ先にHerokuを選択しましたが、AWSでもGCPでもやることは変わらないかと思います。

ruby-saml、本当に優秀です。


余談

完全に余談なんですがLINE WORKSのdevelopers画面はだいぶ簡素な作りなので改善の余地があるかと思われます。。。

ruby-saml を使ってSSOを試してみる with LINE WORKS

こんにちは!kossyです!

さて、今回はSAML認証のクライアント側を実装できるGem「ruby-saml」でLINE WORKSのSAML2.0でSSOを試してみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina


saml-spレポジトリの作成

SAMLのServiceProvider側を作成するために、Railsアプリを作成します。今回はsaml-spというレポジトリ名とします。

$ rails new saml-sp -d postgresql -T

$ cd saml-sp

$ rails db:create

$ rails s

これでlocalhost:3000にアクセスしまして、毎度お馴染み「Yay!」の画面が表示されればOKです。

ruby-samlの導入

次に、Gemfileを修正します。

# Gemfile

gem 'ruby-saml'
gem 'figaro' # 環境変数を管理するために使います。必須ではないです

で bundle。

$ bundle

次にfigaroのinstallコマンドを叩きます。

$ bundle exec figaro install

      create  config/application.yml
      append  .gitignore

このコマンドで作成されたconfig/application.ymlファイルに秘密情報を記載していきます。


認証用のモデルを作成

次に認証用のAccountモデルを作成します。

$ rails g model Account

$ rails db:migrate

作成されたapp/models/account.rbに以下のメソッドを追加します。(中身が空ですが後ほど実装します。)

class Account < ApplicationRecord
  # 追加
  def self.get_saml_settings(url_base)
  end
end

routing/controller/viewsの定義

順に定義していきます。

# config/routes.rb

  resources :saml, only: :index do
    collection do
      get :sso
      post :acs
      get :metadata
      get :logout
    end
  end

  root 'saml#index'
# app/controllers/saml_controller.rb

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

  def index
    @attrs = {}
  end

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

    request = OneLogin::RubySaml::Authrequest.new

    redirect_to(request.create(settings))
  end

  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

  def metadata
    settings = Account.get_saml_settings(get_url_base)
    meta = OneLogin::RubySaml::Metadata.new
    render :xml => meta.generate(settings, true)
  end

  # Trigger SP and IdP initiated Logout requests
  def logout
    # If we're given a logout request, handle it in the IdP logout initiated method
    if params[:SAMLRequest]
      return idp_logout_request

    # We've been given a response back from the IdP
    elsif params[:SAMLResponse]
      return process_logout_response
    elsif params[:slo]
      return sp_logout_request
    else
      reset_session
    end
  end

  # Create an SP initiated SLO
  def sp_logout_request
    # LogoutRequest accepts plain browser requests w/o paramters
    settings = Account.get_saml_settings(get_url_base)

    if settings.idp_slo_target_url.nil?
      logger.info "SLO IdP Endpoint not found in settings, executing then a normal logout'"
      reset_session
    else

      # Since we created a new SAML request, save the transaction_id
      # to compare it with the response we get back
      logout_request = OneLogin::RubySaml::Logoutrequest.new()
      session[:transaction_id] = logout_request.uuid
      logger.info "New SP SLO for User ID: '#{session[:nameid]}', Transaction ID: '#{session[:transaction_id]}'"

      if settings.name_identifier_value.nil?
        settings.name_identifier_value = session[:nameid]
      end

      relayState = url_for controller: 'saml', action: 'index'
      redirect_to(logout_request.create(settings, :RelayState => relayState))
    end
  end

  # After sending an SP initiated LogoutRequest to the IdP, we need to accept
  # the LogoutResponse, verify it, then actually delete our session.
  def process_logout_response
    settings = Account.get_saml_settings(get_url_base)
    request_id = session[:transaction_id]
    logout_response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings, :matches_request_id => request_id, :get_params => params)
    logger.info "LogoutResponse is: #{logout_response.response.to_s}"

    # Validate the SAML Logout Response
    if not logout_response.validate
      error_msg = "The SAML Logout Response is invalid.  Errors: #{logout_response.errors}"
      logger.error error_msg
      render :inline => error_msg
    else
      # Actually log out this session
      if logout_response.success?
        logger.info "Delete session for '#{session[:nameid]}'"
        reset_session
      end
    end
  end

  # Method to handle IdP initiated logouts
  def idp_logout_request
    settings = Account.get_saml_settings(get_url_base)
    logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest], :settings => settings)
    if not logout_request.is_valid?
      error_msg = "IdP initiated LogoutRequest was not valid!. Errors: #{logout_request.errors}"
      logger.error error_msg
      render :inline => error_msg
    end
    logger.info "IdP initiated Logout for #{logout_request.nameid}"

    # Actually log out this session
    reset_session

    logout_response = OneLogin::RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, :RelayState => params[:RelayState])
    redirect_to logout_response
  end

  def get_url_base
	"#{request.protocol}#{request.host_with_port}"
  end
end
# app/views/saml/fail.html.erb

<html>
  <body>
  <h4>SAML Response invalid</h4>
    <% if @errors %>
      <% @errors.each do |error| %>
        <p><%= error %></p>
      <% end %>
    <% end %>
  </body>
</html>
# app/views/saml/index.html.erb

<% if session[:nameid].present? %>
  <p>NameID: <%= session[:nameid] %></p>

  <% if @attrs.any? %>
    <p>Received the following attributes in the SAML Response:</p>
    <table><thead><th>Name</th><th>Values</th></thead><tbody>

    <% @attrs.each do |key,attr_value|  %>

      <tr><td><%= key %></td>
      <td>
      <% if attr_value.any? %>
          <ul>
          <% attr_value.each do |val| %>
              <li><%= val %></li>
          <% end %>
          </ul>
      <% end %>
      </td></tr>
    <% end %>
    </tbody>
    </table>
  <% end %>
  <p><%= link_to "Logout", :action => "logout" %></p>
  <p><%= link_to "Single Logout", :action => "logout", :slo => '1' %></p>

<% else %>
  <p><%= link_to "Login", :action=>"sso"%></p>
<% end -%>
# app/views/saml/logout.html.erb

<p>Logged out</p>

<p><%= link_to "Login", :action=>"sso"%></p>
# app/views/saml/no_settings.html.erb

<h2>No Settings found</h2>

ここまで実装できましたら、一旦動作の確認をしてみます。(get_saml_settingsメソッドが空なので当然意図通り動きませんが、、、)

localhost:3000 にアクセスすると、Loginというリンクがあるページが表示されます。

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

クリックすると以下の表示になるはずです。

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

saml_controller.rbのssoアクションのコードを見ると、

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

    request = OneLogin::RubySaml::Authrequest.new

    redirect_to(request.create(settings))
  end

settingsがnilの場合はno_settingsをレンダリングするため、NoSettingのページに遷移しています。

あとはget_saml_settingsメソッドの中身を実装していくだけです。


get_saml_settingsメソッドの実装

では本丸のメソッドの実装です。

get_saml_settingsメソッドを以下のように書き換えてください。

  def self.get_saml_settings(url_base)
    # this is just for testing purposes.
    # should retrieve SAML-settings based on subdomain, IP-address, NameID or similar
    settings = OneLogin::RubySaml::Settings.new

    url_base ||= "http://localhost:3000"

    # Example settings data, replace this values!

    # When disabled, saml validation errors will raise an exception.
    settings.soft = true

    #SP section
    settings.issuer                         = url_base + "/saml/metadata"
    settings.assertion_consumer_service_url = url_base + "/saml/acs"
    settings.assertion_consumer_logout_service_url = url_base + "/saml/logout"
    settings.protocol_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" # 指定しないとGet Request で /saml/acs を叩いてくるため

    idp_base_url = "https://auth.worksmobile.com/saml2"

    # 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.name_identifier_format         = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"

    # Security section
    settings.security[:authn_requests_signed] = false
    settings.security[:logout_requests_signed] = false
    settings.security[:logout_responses_signed] = false
    settings.security[:metadata_signed] = false
    settings.security[:digest_method] = XMLSecurity::Document::SHA1
    settings.security[:signature_method] = XMLSecurity::Document::RSA_SHA1

    settings
  end

ほとんどruby-samlのexampleのコピペだったりするんですが、いくつか異なる点があります。

1つ目は、settings.protocol_binding属性にurn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST を指定している点です。

LINE WORKSのSAMLのドキュメントを読むと、

SAML Request に指定した Protocol Binding により、GET または POST を使用します。
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" の場合は POST、それ以外は GET を使用します。

出典: https://developers.worksmobile.com/jp/document/1001500302?lang=ja

との記載があります。

先ほど設定したconfig/routes.rbの定義を確認すると、

      post :acs

となっており、POSTリクエストで/acsが叩かれることを想定しているため、上記の設定を加えることでPOSTリクエストで叩かれるようにしています。(これにハマって1日溶かしました。ドキュメント読むの大事ですね。)

2つ目は、IdPセクションの設定値を環境変数にしていることですね。



これで準備完了!と言いたいところですが、肝心のLINE WORKSのアカウントがないので作成します。


LINE WORKS にアカウントを作成

LINE WORKSでSAML2.0によるSSO機能を使うには、有料アカウントで登録する必要があります。(2021年10月時点。ライトプラン・ユーザー一人当たり月額360円)

360円は自己投資だと思うようにしましょう(技術書よりは全然安いし、、、)

line.worksmobile.com

料金表はこちらです。

line.worksmobile.com


まずは無料でアカウントを作成し、その後にライトプランにアップグレードする必要があります。


SAML Appsの登録

無事課金し終えてライトプランにアップグレードできましたら、LINE WORKSのデベロッパーコンソールからSSO機能を利用するアプリの登録を行う必要があります。

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

・Application Nameにはsaml-sp

ACS URLには http://localhost:3000/saml/acs

・SP Issuer(Entity ID)には http://localhost:3000/saml/metadata

を入力してください。

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

「次へ」をクリックし、以下の表示が出れば設定成功です。

f:id:kossy-web-engineer:20211016211905p:plain
registered

登録後、以下URLにアクセスすると、

https://developers.worksmobile.com/jp/console/idp/saml/view

f:id:kossy-web-engineer:20211016212325p:plain
SAML Apps

先ほど追加したサービスが表示されていると思いますので、まずは使用状態を「無効」から「有効」に切り替えます。

「変更」をクリックすると、以下のモーダルが表示されますので、ラジオボタンの「有効」をクリックし、「保存」をクリックすると、使用状態を切り替えられます。

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

次に、「LINE WORKS Identity Provider情報」をクリックすると、SSO URL と Response Issuer の確認と Certificate のダウンロードが行えますので、

SSO URL と Response Issuer は一旦どこかにコピペしておき、 Certificateのダウンロードを行ってください。

f:id:kossy-web-engineer:20211016212856p:plain
Identity_provider_information

ダウンロードしたCertificateを使って、以下コマンドを叩いてフィンガープリントを取得してください。

$ openssl x509 -text -noout -in ~/Downloads/<your file name> -fingerprint -sha256

出力された値はおそらく以下のような感じになるはず。

SHA256 Fingerprint=59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM

この値を、application.ymlに追記してください。
ついでにentity_idやsso_target_urlで使う値も環境変数にします。

config/application.yml

IDP_CERT_FINGERPRINT: 59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM
IDP_GROUP_NAME: your_group_name

これで準備完了です。動作を確認してみましょう。



localhost:3000にアクセス

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

LoginをクリックするとLINE WORKSのログイン画面にリダイレクトする

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

ログインに成功すると、ローカルのアプリにリダイレクトして、以下の画面が表示される

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

ログアウトも可能

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

Single Logoutの方は正しく動作しなかったので、いつか検証してブログにします。


まとめ

少々ハマりどころもありましたが、ruby-saml Gemのexampleを参考にすれば簡単に動作確認まで行うことができました。これが本番運用となるとまた考慮するポイントが変わってくるとは思いますが、

それは追々ブログにまとめたいと思います。(まずはherokuとかからかな)

あとはdevise_saml_authenticatable等のGemではどう実装すればいいか?などもありますので、まだまだSAMLについての記事を書いていくことになると思います。