diff --git a/app/controllers/concerns/saml_idp_logout_concern.rb b/app/controllers/concerns/saml_idp_logout_concern.rb index 7ff51fbc23a..f7979d4ae16 100644 --- a/app/controllers/concerns/saml_idp_logout_concern.rb +++ b/app/controllers/concerns/saml_idp_logout_concern.rb @@ -21,6 +21,32 @@ def handle_valid_sp_logout_request sign_out if user_signed_in? end + def handle_valid_sp_remote_logout_request(user_id) + # Remotely invalidate the user's current session, see config/initializers/session_limitable.rb + User.find(user_id).update!(unique_session_id: nil) + + render_template_for( + Base64.strict_encode64(logout_response), + saml_request.response_url, + 'SAMLResponse', + ) + end + + def find_user_from_session_index + uuid = saml_request.session_index + issuer = saml_request.issuer + agency_id = ServiceProvider.find_by(issuer: issuer).agency_id + AgencyIdentity.find_by(agency_id: agency_id, uuid: uuid)&.user_id + end + + def valid_remote_logout_user_id?(user_id) + return false unless user_id.present? + ServiceProviderIdentity.where( + user_id: user_id, + service_provider: saml_request.issuer, + ).count.positive? + end + def logout_response encode_response( current_user, @@ -38,6 +64,14 @@ def track_logout_event ) end + def track_remote_logout_event + analytics.track_event( + Analytics::REMOTE_LOGOUT_INITIATED, + service_provider: saml_request&.issuer, + saml_request_valid: valid_saml_request?, + ) + end + def saml_response_signature_options endpoint = SamlEndpoint.new(request) { diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index f4e130d644b..ffd38c409be 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -48,6 +48,23 @@ def logout handle_valid_sp_logout_request end + def remotelogout + raw_saml_request = params[:SAMLRequest] + return head(:bad_request) if raw_saml_request.nil? + + decode_request(raw_saml_request) + + track_remote_logout_event + + return head(:bad_request) unless valid_saml_request? + + user_id = find_user_from_session_index + + return head(:bad_request) unless valid_remote_logout_user_id?(user_id) + + handle_valid_sp_remote_logout_request(user_id) + end + private def confirm_user_is_authenticated_with_fresh_mfa diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 90d2811b649..865bcba591d 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -228,6 +228,7 @@ def browser_attributes RATE_LIMIT_TRIGGERED = 'Rate Limit Triggered'.freeze RESPONSE_TIMED_OUT = 'Response Timed Out'.freeze REMEMBERED_DEVICE_USED_FOR_AUTH = 'Remembered device used for authentication'.freeze + REMOTE_LOGOUT_INITIATED = 'Remote Logout initiated'.freeze RETURN_TO_SP_CANCEL = 'Return to SP: Cancelled'.freeze RETURN_TO_SP_FAILURE_TO_PROOF = 'Return to SP: Failed to proof'.freeze RULES_OF_USE_VISIT = 'Rules Of Use Visited'.freeze diff --git a/config/routes.rb b/config/routes.rb index aa0dd3503a0..d5247ffe566 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,7 @@ SamlEndpoint.suffixes.each do |suffix| get "/api/saml/metadata#{suffix}" => 'saml_idp#metadata', format: false match "/api/saml/logout#{suffix}" => 'saml_idp#logout', via: %i[get post delete] + match "/api/saml/remotelogout#{suffix}" => 'saml_idp#remotelogout', via: %i[get post delete] # JS-driven POST redirect route to preserve existing session post "/api/saml/auth#{suffix}" => 'saml_post#auth' # actual SAML handling POST route diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 460e937da4b..33adbc37e5a 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -111,6 +111,184 @@ end end + describe '/api/saml/remotelogout' do + it 'tracks the event when the saml request is invalid' do + stub_analytics + + result = { service_provider: nil, saml_request_valid: false } + expect(@analytics).to receive(:track_event).with(Analytics::REMOTE_LOGOUT_INITIATED, result) + + delete :remotelogout, params: { SAMLRequest: 'foo' } + end + + let(:service_provider) do + create( + :service_provider, + certs: ['sp_sinatra_demo', 'saml_test_sp'], + active: true, + assertion_consumer_logout_service_url: 'https://example.com', + ) + end + + let(:user) { create(:user, :signed_up, unique_session_id: 'abc123') } + let!(:identity) do + ServiceProviderIdentity.create( + service_provider: service_provider.issuer, + user: user, + ) + end + let!(:agency_identity) do + AgencyIdentity.create!( + agency: service_provider.agency, + user: user, + uuid: identity.uuid, + ) + end + + let(:right_cert_settings) do + saml_settings( + overrides: { + issuer: service_provider.issuer, + assertion_consumer_logout_service_url: 'https://example.com', + sessionindex: agency_identity.uuid, + }, + ) + end + + let(:right_cert_no_session_settings) do + saml_settings( + overrides: { + issuer: service_provider.issuer, + assertion_consumer_logout_service_url: 'https://example.com', + }, + ) + end + + let(:right_cert_bad_session_settings) do + saml_settings( + overrides: { + issuer: service_provider.issuer, + assertion_consumer_logout_service_url: 'https://example.com', + sessionindex: 'abc123', + }, + ) + end + + let(:wrong_cert_settings) do + saml_settings( + overrides: { + issuer: service_provider.issuer, + certificate: File.read(Rails.root.join('certs', 'sp', 'saml_test_sp2.crt')), + private_key: OpenSSL::PKey::RSA.new( + File.read(Rails.root + 'keys/saml_test_sp2.key'), + ).to_pem, + }, + ) + end + + it 'accepts requests from a correct cert and correct session index' do + saml_request = UriService.params( + OneLogin::RubySaml::Logoutrequest.new.create(right_cert_settings), + )[:SAMLRequest] + + payload = [ + ['SAMLRequest', saml_request], + ['RelayState', 'aaa'], + ['SigAlg', 'SHA256'], + ] + canon_string = payload.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join('&') + + private_sp_key = OpenSSL::PKey::RSA.new(right_cert_settings.private_key) + signature = private_sp_key.sign(OpenSSL::Digest.new('SHA256'), canon_string) + + certificate = OpenSSL::X509::Certificate.new(right_cert_settings.certificate) + + # This is the same verification process we expect the SAML gem will run + expect( + certificate.public_key.verify( + OpenSSL::Digest.new('SHA256'), + signature, + canon_string, + ), + ).to eq(true) + + delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + + expect(response).to be_ok + expect(User.find(user.id).unique_session_id).to be_nil + end + + it 'rejects requests from a correct cert but no session index' do + saml_request = UriService.params( + OneLogin::RubySaml::Logoutrequest.new.create(right_cert_no_session_settings), + )[:SAMLRequest] + + payload = [ + ['SAMLRequest', saml_request], + ['RelayState', 'aaa'], + ['SigAlg', 'SHA256'], + ] + canon_string = payload.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join('&') + + private_sp_key = OpenSSL::PKey::RSA.new(right_cert_settings.private_key) + signature = private_sp_key.sign(OpenSSL::Digest.new('SHA256'), canon_string) + + certificate = OpenSSL::X509::Certificate.new(right_cert_settings.certificate) + + # This is the same verification process we expect the SAML gem will run + expect( + certificate.public_key.verify( + OpenSSL::Digest.new('SHA256'), + signature, + canon_string, + ), + ).to eq(true) + + delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + + expect(response).to be_bad_request + end + + it 'rejects requests from a correct cert but bad session index' do + saml_request = UriService.params( + OneLogin::RubySaml::Logoutrequest.new.create(right_cert_bad_session_settings), + )[:SAMLRequest] + + payload = [ + ['SAMLRequest', saml_request], + ['RelayState', 'aaa'], + ['SigAlg', 'SHA256'], + ] + canon_string = payload.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join('&') + + private_sp_key = OpenSSL::PKey::RSA.new(right_cert_settings.private_key) + signature = private_sp_key.sign(OpenSSL::Digest.new('SHA256'), canon_string) + + certificate = OpenSSL::X509::Certificate.new(right_cert_settings.certificate) + + # This is the same verification process we expect the SAML gem will run + expect( + certificate.public_key.verify( + OpenSSL::Digest.new('SHA256'), + signature, + canon_string, + ), + ).to eq(true) + + delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + + expect(response).to be_bad_request + end + + it 'rejects requests from a wrong cert' do + delete :remotelogout, params: UriService.params( + OneLogin::RubySaml::Logoutrequest.new.create(wrong_cert_settings), + ) + + expect(response).to be_bad_request + end + end + describe '/api/saml/metadata' do before do get :metadata