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
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アクションを見てみます。
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でデバッグしながらコードを追ってみます。
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
def authenticate!(*args)
user, opts = _perform_authentication(*args)
throw(:warden, opts) unless user
user
end
$ /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:354 Warden::Proxy
def _run_strategies_for(scope, args)
self.winning_strategy = @winning_strategies[scope]
return if winning_strategy && winning_strategy.halted?
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]}
$ strategies = defaults[scope] || defaults[:_all]
=> [:saml_authenticatable, :rememberable, :database_authenticatable]
$ /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:380 Warden::Proxy
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
$ 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
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
$ decorated_response = ::SamlAuthenticatable::SamlResponse.new(saml_response, attribute_map(saml_response))
$ Devise.saml_use_subject
=> true
$ auth_value = saml_response.name_id
=> "your_idp_id@your_idp_domain"
$ auth_value.try(:downcase!) if Devise.case_insensitive_keys.include?(key)
$ resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)
=>
$ Devise.saml_resource_validator
=> nil
$ Devise.saml_resource_validator_hook
=> nil
$ resource.nil?
=> false
$ Devise.saml_update_user
=> false
$ (resource.new_record? && Devise.saml_create_user)
=> false
一通り追ってみましたが、実際にユーザーを取得しているっぽい処理はDevise.saml_resource_locator.callメソッドだと思われるので、stepでどんな処理をしているか見に行ってみます。
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
=>
なるほどモデル名に対してwhereメソッドでDevise.saml_default_user_keyに指定した属性をauth_valueで検索しに行く処理でした。
これも余談ですが、where + first は find_by で置き換えられると思うので、細かいですが修正PRを出してもいいかもしれないですね、、、
これで概ね追えたと思います。SamlAuthenticatable::SamlResponseクラスの処理や、attribute-map.yml周りのコードは別の記事で追ってみたいと思います。(もう12000字超えてるし、、、)