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:
Cody Cutrer 2017-04-13 13:32:12 -06:00
parent bcdeaa30f1
commit 470022912b
3 changed files with 99 additions and 69 deletions

View File

@ -192,31 +192,31 @@ class Login::SamlController < ApplicationController
end
end
def destroy
unless params[:SAMLResponse] || params[:SAMLRequest]
return render status: :bad_request, plain: "SAMLRequest or SAMLResponse required"
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
if params[:SAMLResponse]
def destroy
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

View File

@ -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],
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],
session[:session_index],
settings
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

View File

@ -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