diff --git a/README.md b/README.md index 833d309..0d3aa11 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ In config/initializers/devise.rb settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" settings.issuer = "http://localhost:3000" settings.authn_context = "" + settings.idp_slo_target_url = "http://localhost/simplesaml/www/saml2/idp/SingleLogoutService.php" settings.idp_sso_target_url = "http://localhost/simplesaml/www/saml2/idp/SSOService.php" settings.idp_cert = <<-CERT.chomp -----BEGIN CERTIFICATE----- @@ -110,10 +111,13 @@ There are numerous IdPs that support SAML 2.0, there are propietary (like Micros [SimpleSAMLphp](http://simplesamlphp.org/) was my choice for development since it is a production-ready SAML solution, that is also really easy to install, configure and use. +## Logout + +Logout support is included by immediately terminating the local session and then redirecting to the IdP. + ## Limitations -1. At the moment there is no support for Single Logout -2. The Authentication Requests (from your app to the IdP) are not signed and encrypted +1. The Authentication Requests (from your app to the IdP) are not signed and encrypted ## Thanks diff --git a/app/controllers/devise/saml_sessions_controller.rb b/app/controllers/devise/saml_sessions_controller.rb index cb2727b..8e31902 100644 --- a/app/controllers/devise/saml_sessions_controller.rb +++ b/app/controllers/devise/saml_sessions_controller.rb @@ -16,6 +16,13 @@ def metadata meta = OneLogin::RubySaml::Metadata.new render :xml => meta.generate(@saml_config) end - + + protected + + # Override devise to send user to IdP logout for SLO + def after_sign_out_path_for(_) + request = OneLogin::RubySaml::Logoutrequest.new + request.create(@saml_config) + end end diff --git a/spec/controllers/devise/saml_sessions_controller_spec.rb b/spec/controllers/devise/saml_sessions_controller_spec.rb index f940196..6647840 100644 --- a/spec/controllers/devise/saml_sessions_controller_spec.rb +++ b/spec/controllers/devise/saml_sessions_controller_spec.rb @@ -1,7 +1,11 @@ require 'rails_helper' class Devise::SessionsController < ActionController::Base - + # The important parts from devise + def destroy + sign_out + redirect_to after_sign_out_path_for(:user) + end end require_relative '../../../app/controllers/devise/saml_sessions_controller' @@ -18,7 +22,7 @@ class Devise::SessionsController < ActionController::Base end describe '#metadata' do - it "generates metadata" do + it 'generates metadata' do get :metadata # Remove ID that can vary across requests @@ -27,4 +31,12 @@ class Devise::SessionsController < ActionController::Base expect(response.body).to match(Regexp.new(metadata_pattern)) end end + + describe '#destroy' do + it 'signs out and redirects to the IdP' do + expect(controller).to receive(:sign_out) + delete :destroy + expect(response).to redirect_to(%r(\Ahttp://localhost:8009/saml/logout\?SAMLRequest=)) + end + end end diff --git a/spec/features/saml_authentication_spec.rb b/spec/features/saml_authentication_spec.rb index aaed202..b2a9492 100644 --- a/spec/features/saml_authentication_spec.rb +++ b/spec/features/saml_authentication_spec.rb @@ -1,5 +1,6 @@ require 'spec_helper' require 'net/http' +require 'timeout' require 'uri' require 'capybara/rspec' require 'capybara/webkit' @@ -32,6 +33,21 @@ expect(page).to have_content("A User") expect(current_url).to eq("http://localhost:8020/") end + + it "logs a user out of the IdP via the SP" do + sign_in + + # prove user is still signed in + visit 'http://localhost:8020/' + expect(page).to have_content("you@example.com") + expect(current_url).to eq("http://localhost:8020/") + + click_on "Log out" + + # prove user is now signed out + visit 'http://localhost:8020/' + expect(current_url).to match(%r(\Ahttp://localhost:8009/saml/auth\?SAMLRequest=)) + end end context "when the attributes are used to authenticate" do @@ -68,4 +84,20 @@ def create_user(email) response = Net::HTTP.post_form(URI('http://localhost:8020/users'), email: email) expect(response.code).to eq('201') end + + def sign_in + visit 'http://localhost:8020/' + expect(current_url).to match(%r(\Ahttp://localhost:8009/saml/auth\?SAMLRequest=)) + fill_in "Email", with: "you@example.com" + fill_in "Password", with: "asdf" + click_on "Sign in" + Timeout.timeout(Capybara.default_wait_time) do + loop do + sleep 0.1 + break if current_url == "http://localhost:8020/" + end + end + rescue Timeout::Error + expect(current_url).to eq("http://localhost:8020/") + end end diff --git a/spec/support/idp_template.rb b/spec/support/idp_template.rb index c9eb24c..f978cfb 100644 --- a/spec/support/idp_template.rb +++ b/spec/support/idp_template.rb @@ -6,5 +6,7 @@ route "get '/saml/auth' => 'saml_idp#new'" route "post '/saml/auth' => 'saml_idp#create'" +route "get '/saml/logout' => 'saml_idp#logout'" template File.expand_path('../saml_idp_controller.rb.erb', __FILE__), 'app/controllers/saml_idp_controller.rb' +copy_file File.expand_path('../saml_idp-saml_slo_post.html.erb', __FILE__), 'app/views/saml_idp/saml_slo_post.html.erb' diff --git a/spec/support/saml_idp-saml_slo_post.html.erb b/spec/support/saml_idp-saml_slo_post.html.erb new file mode 100644 index 0000000..dc5fb46 --- /dev/null +++ b/spec/support/saml_idp-saml_slo_post.html.erb @@ -0,0 +1,13 @@ + + + + + + + +<%= form_tag(@saml_slo_acs_url) do %> + <%= hidden_field_tag("SAMLResponse", @saml_slo_response) %> + <%= submit_tag "Submit" %> +<% end %> + + diff --git a/spec/support/saml_idp_controller.rb.erb b/spec/support/saml_idp_controller.rb.erb index ff640b7..0144f59 100644 --- a/spec/support/saml_idp_controller.rb.erb +++ b/spec/support/saml_idp_controller.rb.erb @@ -1,9 +1,21 @@ class SamlIdpController < SamlIdp::IdpController + def new + if session[:user_id] + @saml_response = idp_make_saml_response(session[:user_id]) + render :template => "saml_idp/idp/saml_post", :layout => false + return + end + super + end + + protected + def idp_authenticate(email, password) + session[:user_id] = "you@example.com" true end - def idp_make_saml_response(user) + def idp_make_saml_response(_) attributes = { "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" => "A User", } @@ -51,4 +63,77 @@ class SamlIdpController < SamlIdp::IdpController def include_subject_in_attributes <%= @include_subject_in_attributes %> end + + # == SLO functionality, see https://github.com/lawrencepit/ruby-saml-idp/pull/10 + skip_before_filter :validate_saml_request, :only => [:logout] + before_filter :validate_saml_slo_request, :only => [:logout] + + public + + def logout + _person, _logout = idp_slo_authenticate(params[:name_id]) + if _person && _logout + @saml_slo_response = idp_make_saml_slo_response(_person) + else + @saml_idp_fail_msg = 'User not found' + logger.error "User with email #{params[:name_id]} not found" + @saml_slo_response = encode_SAML_SLO_Response(params[:name_id]) + end + if @saml_slo_acs_url + render :template => "saml_idp/idp/saml_slo_post", :layout => false + else + redirect_to 'http://example.com' + end + end + + def idp_slo_authenticate(email) + session.delete :user_id + true + end + + def idp_make_saml_slo_response(person) + attributes = {} + if include_subject_in_attributes + attributes["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] = "you@example.com" + end + encode_SAML_SLO_Response("you@example.com", attributes: attributes) + end + + private + + def validate_saml_slo_request(saml_request = params[:SAMLRequest]) + decode_SAML_SLO_Request(saml_request) + end + + def decode_SAML_SLO_Request(saml_request) + zstream = Zlib::Inflate.new(-Zlib::MAX_WBITS) + @saml_slo_request = zstream.inflate(Base64.decode64(saml_request)) + zstream.finish + zstream.close + @saml_slo_request_id = @saml_slo_request[/ID=['"](.+?)['"]/, 1] + @saml_slo_acs_url = @saml_slo_request[/AssertionConsumerLogoutServiceURL=['"](.+?)['"]/, 1] + end + + def encode_SAML_SLO_Response(nameID, opts = {}) + now = Time.now.utc + response_id, reference_id = UUID.generate, UUID.generate + audience_uri = opts[:audience_uri] || (@saml_slo_acs_url && @saml_slo_acs_url[/^(.*?\/\/.*?\/)/, 1]) + issuer_uri = opts[:issuer_uri] || (defined?(request) && request.url.split("?")[0]) || "http://example.com" + + assertion = %[#{issuer_uri}#{nameID}#{audience_uri}#{nameID}urn:federation:authentication:windows] + + digest_value = Base64.encode64(algorithm.digest(assertion)).gsub(/\n/, '') + + signed_info = %[#{digest_value}] + + signature_value = sign(signed_info).gsub(/\n/, '') + + signature = %[#{signed_info}#{signature_value}#{self.x509_certificate}] + + assertion_and_signature = assertion.sub(/Issuer\>\#{signature}#{issuer_uri}#{assertion_and_signature}] + + Base64.encode64(xml) + end end diff --git a/spec/support/sp_template.rb b/spec/support/sp_template.rb index 3c0f487..7967da4 100644 --- a/spec/support/sp_template.rb +++ b/spec/support/sp_template.rb @@ -18,7 +18,12 @@ AUTHENTICATE } insert_into_file('app/views/home/index.html.erb', after: /\z/) { - "<%= current_user.email %> <%= current_user.name %>" + <<-HOME +<%= current_user.email %> <%= current_user.name %> +<%= form_tag destroy_user_session_path, method: :delete do %> + <%= submit_tag "Log out" %> +<% end %> + HOME } route "root to: 'home#index'" @@ -33,6 +38,7 @@ config.saml_configure do |settings| settings.assertion_consumer_service_url = "http://localhost:8020/users/saml/auth" settings.issuer = "http://localhost:8020/saml/metadata" + settings.idp_slo_target_url = "http://localhost:8009/saml/logout" settings.idp_sso_target_url = "http://localhost:8009/saml/auth" settings.idp_cert_fingerprint = "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D" end @@ -41,7 +47,7 @@ generate :devise, "user", "email:string", "name:string" gsub_file 'app/models/user.rb', /database_authenticatable.*\n.*/, 'saml_authenticatable' - route "resources :users" + route "resources :users, only: [:create]" create_file('app/controllers/users_controller.rb', <<-USERS) class UsersController < ApplicationController skip_before_filter :verify_authenticity_token