こんにちは!kossyです!
今回は、DeviseベースでSAML SSOを実現する 「devise_saml_authenticatable」のソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。
newアクション
上記のセクションを見ると、
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にリダイレクトされます。
とのことなので、まずは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のコードリーディングは以前書いた拙著でも取り上げています。
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?メソッドが呼ばれます。
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のコードです。
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字超えてるし、、、)