use SAML2 gem for SAML logout flows
fixes CNVS-36681 requests and responses in both directions test plan: * configure Canvas to do SLO with an IdP that supports SLO * log in to Canvas, and log out of Canvas. It should not give any errors (you may want to run through this step without this patchset to familiarize yourself with the flow) * log in to canvas, and log out at your IdP; observe that your Canvas session was also terminated (again, verify beforehand that this is working with your IdP with the old code) Change-Id: I5b593fc4338b8ea8ad94e1b53fc91d72e712a317 Reviewed-on: https://gerrit.instructure.com/108544 Reviewed-by: Tyler Pickett <tpickett@instructure.com> Tested-by: Jenkins QA-Review: Jeremy Putnam <jeremyp@instructure.com> Product-Review: Cody Cutrer <cody@instructure.com>
This commit is contained in:
parent
bcdeaa30f1
commit
470022912b
|
@ -192,31 +192,31 @@ class Login::SamlController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
rescue_from SAML2::InvalidMessage, with: :saml_error
|
||||
def saml_error(error)
|
||||
Canvas::Errors.capture_exception(:saml, error)
|
||||
render status: :bad_request, plain: error.to_s
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless params[:SAMLResponse] || params[:SAMLRequest]
|
||||
return render status: :bad_request, plain: "SAMLRequest or SAMLResponse required"
|
||||
end
|
||||
|
||||
if params[:SAMLResponse]
|
||||
message, relay_state = SAML2::Bindings::HTTPRedirect.decode(request.url)
|
||||
case message
|
||||
when SAML2::LogoutResponse
|
||||
increment_saml_stat("logout_response_received")
|
||||
saml_response = Onelogin::Saml::LogoutResponse.parse(params[:SAMLResponse])
|
||||
|
||||
aac = @domain_root_account.authentication_providers.active.where(idp_entity_id: saml_response.issuer).first
|
||||
aac = @domain_root_account.authentication_providers.active.where(idp_entity_id: message.issuer.id).first
|
||||
return render status: :bad_request, plain: "Could not find SAML Entity" unless aac
|
||||
|
||||
settings = aac.saml_settings(request.host_with_port)
|
||||
saml_response.process(settings)
|
||||
|
||||
if aac.debugging? && aac.debug_get(:logout_request_id) == saml_response.in_response_to
|
||||
if aac.debugging? && aac.debug_get(:logout_request_id) == message.in_response_to
|
||||
aac.debug_set(:idp_logout_response_encoded, params[:SAMLResponse])
|
||||
aac.debug_set(:idp_logout_response_xml_encrypted, saml_response.xml)
|
||||
aac.debug_set(:idp_logout_response_in_response_to, saml_response.in_response_to)
|
||||
aac.debug_set(:idp_logout_response_destination, saml_response.destination)
|
||||
aac.debug_set(:idp_logout_response_xml_encrypted, message.xml.to_xml)
|
||||
aac.debug_set(:idp_logout_response_in_response_to, message.in_response_to)
|
||||
aac.debug_set(:idp_logout_response_destination, message.destination)
|
||||
aac.debug_set(:debugging, t('debug.logout_response_redirect_from_idp', "Received LogoutResponse from IdP"))
|
||||
end
|
||||
|
||||
unless saml_response.success_status?
|
||||
logger.error "Failed SAML LogoutResponse: #{saml_response.status_code}: #{saml_response.status_message}"
|
||||
unless message.status.code == SAML2::Status::SUCCESS
|
||||
logger.error "Failed SAML LogoutResponse: #{message.status.code}: #{message.status.message}"
|
||||
flash[:delegated_message] = t("There was a failure logging out at your IdP")
|
||||
return redirect_to login_url
|
||||
end
|
||||
|
@ -256,41 +256,41 @@ class Login::SamlController < ApplicationController
|
|||
return
|
||||
end
|
||||
|
||||
redirect_to saml_login_url(id: aac.id)
|
||||
else
|
||||
return redirect_to saml_login_url(id: aac.id)
|
||||
when SAML2::LogoutRequest
|
||||
increment_saml_stat("logout_request_received")
|
||||
saml_request = Onelogin::Saml::LogoutRequest.parse(params[:SAMLRequest])
|
||||
aac = @domain_root_account.authentication_providers.active.where(idp_entity_id: saml_request.issuer).first
|
||||
aac = @domain_root_account.authentication_providers.active.where(idp_entity_id: message.issuer.id).first
|
||||
return render status: :bad_request, plain: "Could not find SAML Entity" unless aac
|
||||
|
||||
settings = aac.saml_settings(request.host_with_port)
|
||||
saml_request.process(settings)
|
||||
|
||||
if aac.debugging? && aac.debug_get(:logged_in_user_id) == @current_user.id
|
||||
aac.debug_set(:idp_logout_request_encoded, params[:SAMLRequest])
|
||||
aac.debug_set(:idp_logout_request_xml_encrypted, saml_request.request_xml)
|
||||
aac.debug_set(:idp_logout_request_name_id, saml_request.name_id)
|
||||
aac.debug_set(:idp_logout_request_session_index, saml_request.session_index)
|
||||
aac.debug_set(:idp_logout_request_destination, saml_request.destination)
|
||||
aac.debug_set(:idp_logout_request_xml_encrypted, message.xml.to_xml)
|
||||
aac.debug_set(:idp_logout_request_name_id, message.name_id.id)
|
||||
aac.debug_set(:idp_logout_request_session_index, message.session_index)
|
||||
aac.debug_set(:idp_logout_request_destination, message.destination)
|
||||
aac.debug_set(:debugging, t('debug.logout_request_redirect_from_idp', "Received LogoutRequest from IdP"))
|
||||
end
|
||||
|
||||
settings.relay_state = params[:RelayState]
|
||||
saml_response = Onelogin::Saml::LogoutResponse.generate(saml_request.id, settings)
|
||||
logout_response = SAML2::LogoutResponse.respond_to(message,
|
||||
aac.idp_metadata.identity_providers.first,
|
||||
SAML2::NameID.new(aac.entity_id))
|
||||
|
||||
# Seperate the debugging out because we want it to log the request even if the response dies.
|
||||
if aac.debugging? && aac.debug_get(:logged_in_user_id) == @current_user.id
|
||||
aac.debug_set(:idp_logout_request_encoded, saml_response.base64_assertion)
|
||||
aac.debug_set(:idp_logout_response_xml_encrypted, saml_response.xml)
|
||||
aac.debug_set(:idp_logout_response_status_code, saml_response.status_code)
|
||||
aac.debug_set(:idp_logout_response_status_message, saml_response.status_message)
|
||||
aac.debug_set(:idp_logout_response_destination, saml_response.destination)
|
||||
aac.debug_set(:idp_logout_response_in_response_to, saml_response.in_response_to)
|
||||
aac.debug_set(:idp_logout_response_xml_encrypted, logout_response.to_s)
|
||||
aac.debug_set(:idp_logout_response_status_code, logout_response.status.code)
|
||||
aac.debug_set(:idp_logout_response_destination, logout_response.destination)
|
||||
aac.debug_set(:idp_logout_response_in_response_to, logout_response.in_response_to)
|
||||
aac.debug_set(:debugging, t('debug.logout_response_redirect_to_idp', "Sending LogoutResponse to IdP"))
|
||||
end
|
||||
|
||||
logout_current_user
|
||||
redirect_to(saml_response.forward_url)
|
||||
|
||||
return redirect_to(SAML2::Bindings::HTTPRedirect.encode(logout_response, relay_state: relay_state))
|
||||
else
|
||||
error = "Unexpected SAML message: #{message.class}"
|
||||
Canvas::Errors.capture_exception(:saml, error)
|
||||
return render status: :bad_request, plain: error
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -179,6 +179,23 @@ class AccountAuthorizationConfig::SAML < AccountAuthorizationConfig::Delegated
|
|||
@saml_settings
|
||||
end
|
||||
|
||||
# construct a metadata doc to represent the IdP
|
||||
# TODO: eventually store the actual metadata we got from the IdP
|
||||
def idp_metadata
|
||||
@idp_metadata ||= begin
|
||||
entity = SAML2::Entity.new
|
||||
entity.entity_id = idp_entity_id
|
||||
|
||||
idp = SAML2::IdentityProvider.new
|
||||
if log_out_url.present?
|
||||
idp.single_logout_services << SAML2::Endpoint.new(log_out_url,
|
||||
SAML2::Endpoint::Bindings::HTTP_REDIRECT)
|
||||
end
|
||||
entity.roles << idp
|
||||
entity
|
||||
end
|
||||
end
|
||||
|
||||
def self.sp_metadata(entity_id, hosts)
|
||||
app_config = ConfigFile.load('saml') || {}
|
||||
|
||||
|
@ -218,6 +235,19 @@ class AccountAuthorizationConfig::SAML < AccountAuthorizationConfig::Delegated
|
|||
sp_metadata(saml_default_entity_id_for_account(account),HostUrl.context_hosts(account, current_host))
|
||||
end
|
||||
|
||||
def self.config
|
||||
ConfigFile.load('saml') || {}
|
||||
end
|
||||
|
||||
def self.private_keys
|
||||
return [] unless (encryption = config[:encryption])
|
||||
([encryption[:private_key]] + Array(encryption[:additional_private_keys])).map do |key|
|
||||
path = resolve_saml_key_path(key)
|
||||
next unless path
|
||||
[path, File.read(path)]
|
||||
end.compact.to_h
|
||||
end
|
||||
|
||||
def self.onelogin_saml_settings_for_account(account, current_host=nil)
|
||||
app_config = ConfigFile.load('saml') || {}
|
||||
domains = HostUrl.context_hosts(account, current_host)
|
||||
|
@ -297,25 +327,29 @@ class AccountAuthorizationConfig::SAML < AccountAuthorizationConfig::Delegated
|
|||
end
|
||||
|
||||
def user_logout_redirect(controller, current_user)
|
||||
settings = saml_settings(controller.request.host_with_port)
|
||||
session = controller.session
|
||||
|
||||
saml_request = Onelogin::Saml::LogoutRequest.generate(
|
||||
session[:name_qualifier],
|
||||
session[:sp_name_qualifer],
|
||||
session[:name_id],
|
||||
session[:name_identifier_format],
|
||||
session[:session_index],
|
||||
settings
|
||||
logout_request = SAML2::LogoutRequest.initiate(idp_metadata.identity_providers.first,
|
||||
SAML2::NameID.new(entity_id),
|
||||
SAML2::NameID.new(session[:name_id],
|
||||
session[:name_identifier_format],
|
||||
name_qualifier: session[:name_qualifier],
|
||||
sp_name_qualifier: session[:sp_name_qualifier]),
|
||||
session[:session_index]
|
||||
)
|
||||
|
||||
# sign the response
|
||||
private_key_data = AccountAuthorizationConfig::SAML.private_keys.first&.last
|
||||
private_key = OpenSSL::PKey::RSA.new(private_key_data) if private_key_data
|
||||
result = SAML2::Bindings::HTTPRedirect.encode(logout_request, private_key: private_key)
|
||||
|
||||
if debugging? && debug_get(:logged_in_user_id) == current_user.id
|
||||
debug_set(:logout_request_id, saml_request.id)
|
||||
debug_set(:logout_to_idp_url, saml_request.forward_url)
|
||||
debug_set(:logout_to_idp_xml, saml_request.xml)
|
||||
debug_set(:logout_request_id, logout_request.id)
|
||||
debug_set(:logout_to_idp_url, result)
|
||||
debug_set(:logout_to_idp_xml, logout_request.to_s)
|
||||
debug_set(:debugging, t('debug.logout_redirect', "LogoutRequest sent to IdP"))
|
||||
end
|
||||
|
||||
saml_request.forward_url
|
||||
result
|
||||
end
|
||||
end
|
||||
|
|
|
@ -288,9 +288,9 @@ describe Login::SamlController do
|
|||
end
|
||||
|
||||
it "should saml_logout with multiple authorization configs" do
|
||||
Onelogin::Saml::LogoutResponse.stubs(:parse).returns(
|
||||
stub('response', @stub_hash)
|
||||
)
|
||||
logout_response = SAML2::LogoutResponse.new
|
||||
logout_response.issuer = SAML2::NameID.new(@aac2.idp_entity_id)
|
||||
expect(SAML2::Bindings::HTTPRedirect).to receive(:decode).and_return(logout_response)
|
||||
controller.request.env['canvas.domain_root_account'] = @account
|
||||
get :destroy, :SAMLResponse => "foo", :RelayState => "/courses"
|
||||
|
||||
|
@ -407,12 +407,12 @@ describe Login::SamlController do
|
|||
end
|
||||
|
||||
it "should find the correct AAC" do
|
||||
@aac1.any_instantiation.expects(:saml_settings).never
|
||||
@aac2.any_instantiation.expects(:saml_settings).at_least_once
|
||||
expect(@aac1.any_instantiation).to receive(:debugging?).never
|
||||
expect(@aac2.any_instantiation).to receive(:debugging?).at_least(1)
|
||||
|
||||
Onelogin::Saml::LogoutResponse.stubs(:parse).returns(
|
||||
stub('response', @stub_hash)
|
||||
)
|
||||
logout_response = SAML2::LogoutResponse.new
|
||||
logout_response.issuer = SAML2::NameID.new(@aac2.idp_entity_id)
|
||||
expect(SAML2::Bindings::HTTPRedirect).to receive(:decode).and_return(logout_response)
|
||||
|
||||
controller.request.env['canvas.domain_root_account'] = @account
|
||||
get :destroy, :SAMLResponse => "foo"
|
||||
|
@ -421,11 +421,9 @@ describe Login::SamlController do
|
|||
|
||||
it "should redirect a response to idp on logout with a SAMLRequest parameter" do
|
||||
controller.expects(:logout_current_user)
|
||||
@stub_hash[:id] = '_42'
|
||||
|
||||
Onelogin::Saml::LogoutRequest.stubs(:parse).returns(
|
||||
stub('request', @stub_hash)
|
||||
)
|
||||
logout_request = SAML2::LogoutRequest.new
|
||||
logout_request.issuer = SAML2::NameID.new(@aac2.idp_entity_id)
|
||||
expect(SAML2::Bindings::HTTPRedirect).to receive(:decode).and_return(logout_request)
|
||||
|
||||
controller.request.env['canvas.domain_root_account'] = @account
|
||||
get :destroy, :SAMLRequest => "foo"
|
||||
|
@ -435,11 +433,9 @@ describe Login::SamlController do
|
|||
end
|
||||
|
||||
it "returns bad request if SAMLRequest parameter doesn't match an AAC" do
|
||||
@stub_hash[:id] = '_42'
|
||||
@stub_hash[:issuer] = "hahahahahahaha"
|
||||
Onelogin::Saml::LogoutRequest.stubs(:parse).returns(
|
||||
stub('request', @stub_hash)
|
||||
)
|
||||
logout_request = SAML2::LogoutRequest.new
|
||||
logout_request.issuer = SAML2::NameID.new("hahahahahahaha")
|
||||
expect(SAML2::Bindings::HTTPRedirect).to receive(:decode).and_return(logout_request)
|
||||
|
||||
controller.request.env['canvas.domain_root_account'] = @account
|
||||
get :destroy, :SAMLRequest => "foo"
|
||||
|
@ -452,9 +448,9 @@ describe Login::SamlController do
|
|||
|
||||
context "/saml_logout" do
|
||||
it "should return bad request if SAML is not configured for account" do
|
||||
Onelogin::Saml::LogoutResponse.expects(:parse).returns(
|
||||
stub('response', issuer: 'entity')
|
||||
)
|
||||
logout_response = SAML2::LogoutResponse.new
|
||||
logout_response.issuer = SAML2::NameID.new('entity')
|
||||
expect(SAML2::Bindings::HTTPRedirect).to receive(:decode).and_return(logout_response)
|
||||
|
||||
controller.expects(:logout_user_action).never
|
||||
controller.request.env['canvas.domain_root_account'] = @account
|
||||
|
|
Loading…
Reference in New Issue