diff --git a/LICENSE b/LICENSE index b55ffa04a..3e8f501a4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2010 OneLogin, LLC +Copyright (c) 2010-2015 OneLogin, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2c8f53a6c..2f2cec8e7 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ def saml_settings settings = OneLogin::RubySaml::Settings.new settings.assertion_consumer_service_url = "http://#{request.host}/saml/finalize" - settings.issuer = request.host + settings.issuer = "http://#{request.host}/saml/metadata" settings.idp_sso_target_url = "https://app.onelogin.com/saml/metadata/#{OneLoginAppId}" settings.idp_entity_id = "https://app.onelogin.com/saml/metadata/#{OneLoginAppId}" settings.idp_sso_target_url = "https://app.onelogin.com/trust/saml2/http-post/sso/#{OneLoginAppId}" @@ -160,7 +160,7 @@ class SamlController < ApplicationController settings = OneLogin::RubySaml::Settings.new settings.assertion_consumer_service_url = "http://#{request.host}/saml/consume" - settings.issuer = request.host + settings.issuer = "http://#{request.host}/saml/metadata" settings.idp_sso_target_url = "https://app.onelogin.com/saml/signon/#{OneLoginAppId}" settings.idp_cert_fingerprint = OneLoginAppCertFingerPrint settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" @@ -196,7 +196,7 @@ def saml_settings settings = idp_metadata_parser.parse_remote("https://example.com/auth/saml2/idp/metadata") settings.assertion_consumer_service_url = "http://#{request.host}/saml/consume" - settings.issuer = request.host + settings.issuer = "http://#{request.host}/saml/metadata" settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" # Optional for most SAML IdPs settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" diff --git a/changelog.md b/changelog.md index 3e730d67d..137f0e392 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,6 @@ * [#179](https://github.com/onelogin/ruby-saml/pull/179) Add support for setting the entity ID and name ID format when parsing metadata * [#175](https://github.com/onelogin/ruby-saml/pull/175) Introduce thread safety to SAML schema validation * [#171](https://github.com/onelogin/ruby-saml/pull/171) Fix inconsistent results with using regex matches in decode_raw_saml -* ### 0.9.1 (Feb 10, 2015) * [#194](https://github.com/onelogin/ruby-saml/pull/194) Relax nokogiri gem requirements diff --git a/lib/onelogin/ruby-saml/attribute_service.rb b/lib/onelogin/ruby-saml/attribute_service.rb index e3aa5f2ff..3ca676d31 100644 --- a/lib/onelogin/ruby-saml/attribute_service.rb +++ b/lib/onelogin/ruby-saml/attribute_service.rb @@ -1,10 +1,15 @@ module OneLogin module RubySaml + + # SAML2 AttributeService. Auxiliary class to build the AttributeService of the SP Metadata + # class AttributeService attr_reader :attributes attr_reader :name attr_reader :index + # Initializes the AttributeService, set the index value as 1 and an empty array as attributes + # def initialize @index = "1" @attributes = [] @@ -14,20 +19,38 @@ def configure(&block) instance_eval &block end + # @return [Boolean] True if the AttributeService object has been initialized and set with the required values + # (has attributes and a name) def configured? @attributes.length > 0 && !@name.nil? end + # Set a name to the service + # @param name [String] The service name + # def service_name(name) @name = name end + # Set an index to the service + # @param index [Integer] An index + # def service_index(index) @index = index end - + + # Add an AttributeService + # @param options [Hash] AttributeService option values + # add_attribute( + # :name => "Name", + # :name_format => "Name Format", + # :index => 1, + # :friendly_name => "Friendly Name", + # :attribute_value => "Attribute Value" + # ) + # def add_attribute(options={}) - attributes << options + attributes << options end end end diff --git a/lib/onelogin/ruby-saml/attributes.rb b/lib/onelogin/ruby-saml/attributes.rb index edaedf155..31d18c312 100644 --- a/lib/onelogin/ruby-saml/attributes.rb +++ b/lib/onelogin/ruby-saml/attributes.rb @@ -1,91 +1,110 @@ module OneLogin module RubySaml - # Wraps all attributes and provides means to query them for single or multiple values. - # - # For backwards compatibility Attributes#[] returns *first* value for the attribute. - # Turn off compatibility to make it return all values as an array: - # Attributes.single_value_compatibility = false + + # SAML2 Attributes. Parse the Attributes from the AttributeStatement of the SAML Response. + # class Attributes include Enumerable + attr_reader :attributes + # By default Attributes#[] is backwards compatible and # returns only the first value for the attribute # Setting this to `false` returns all values for an attribute @@single_value_compatibility = true - # Get current status of backwards compatibility mode. + # @return [Boolean] Get current status of backwards compatibility mode. + # def self.single_value_compatibility @@single_value_compatibility end # Sets the backwards compatibility mode on/off. + # @param value [Boolean] + # def self.single_value_compatibility=(value) @@single_value_compatibility = value end - # Initialize Attributes collection, optionally taking a Hash of attribute names and values. - # - # The +attrs+ must be a Hash with attribute names as keys and **arrays** as values: + # @param attrs [Hash] The +attrs+ must be a Hash with attribute names as keys and **arrays** as values: # Attributes.new({ # 'name' => ['value1', 'value2'], # 'mail' => ['value1'], # }) + # def initialize(attrs = {}) @attributes = attrs end # Iterate over all attributes + # def each attributes.each{|name, values| yield name, values} end + # Test attribute presence by name + # @param name [String] The attribute name to be checked + # def include?(name) attributes.has_key?(canonize_name(name)) end # Return first value for an attribute + # @param name [String] The attribute name + # @return [String] The value (First occurrence) + # def single(name) attributes[canonize_name(name)].first if include?(name) end # Return all values for an attribute + # @param name [String] The attribute name + # @return [Array] Values of the attribute + # def multi(name) attributes[canonize_name(name)] end - # By default returns first value for an attribute. - # - # Depending on the single value compatibility status this returns first value - # Attributes.single_value_compatibility = true # Default - # response.attributes['mail'] # => 'user@example.com' + # Retrieve attribute value(s) + # @param name [String] The attribute name + # @return [String|Array] Depending on the single value compatibility status this returns: + # - First value if single_value_compatibility = true + # response.attributes['mail'] # => 'user@example.com' + # - All values if single_value_compatibility = false + # response.attributes['mail'] # => ['user@example.com','user@example.net'] # - # Or all values: - # Attributes.single_value_compatibility = false - # response.attributes['mail'] # => ['user@example.com','user@example.net'] def [](name) self.class.single_value_compatibility ? single(canonize_name(name)) : multi(canonize_name(name)) end - # Return all attributes as an array + # @return [Array] Return all attributes as an array + # def all attributes end - # Set values for an attribute, overwriting all existing values + # @param name [String] The attribute name + # @param values [Array] The values + # def set(name, values) attributes[canonize_name(name)] = values end alias_method :[]=, :set - # Add new attribute or new value(s) to an existing attribute + # @param name [String] The attribute name + # @param values [Array] The values + # def add(name, values = []) attributes[canonize_name(name)] ||= [] attributes[canonize_name(name)] += Array(values) end # Make comparable to another Attributes collection based on attributes + # @param other [Attributes] An Attributes object to compare with + # @return [Boolean] True if are contains the same attributes and values + # def ==(other) if other.is_a?(Attributes) all == other.all @@ -97,13 +116,13 @@ def ==(other) protected # stringifies all names so both 'email' and :email return the same result + # @param name [String] The attribute name + # @return [String] stringified name + # def canonize_name(name) name.to_s end - def attributes - @attributes - end end end end diff --git a/lib/onelogin/ruby-saml/authrequest.rb b/lib/onelogin/ruby-saml/authrequest.rb index f12e797ae..3d5471acb 100644 --- a/lib/onelogin/ruby-saml/authrequest.rb +++ b/lib/onelogin/ruby-saml/authrequest.rb @@ -4,17 +4,30 @@ require "onelogin/ruby-saml/logging" require "onelogin/ruby-saml/saml_message" +# Only supports SAML 2.0 module OneLogin module RubySaml include REXML + + # SAML2 Authentication. AuthNRequest (SSO SP initiated, Builder) + # class Authrequest < SamlMessage - attr_reader :uuid # Can be obtained if neccessary + # AuthNRequest ID + attr_reader :uuid + # Initializes the AuthNRequest. An Authrequest Object that is an extension of the SamlMessage class. + # Asigns an ID, a random uuid. + # def initialize @uuid = "_" + UUID.new.generate end + # Creates the AuthNRequest string. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [String] AuthNRequest string that includes the SAMLRequest + # def create(settings, params = {}) params = create_params(settings, params) params_prefix = (settings.idp_sso_target_url =~ /\?/) ? '&' : '?' @@ -26,6 +39,11 @@ def create(settings, params = {}) @login_url = settings.idp_sso_target_url + request_params end + # Creates the Get parameters for the request. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [Hash] Parameters + # def create_params(settings, params={}) # The method expects :RelayState but sometimes we get 'RelayState' instead. # Based on the HashWithIndifferentAccess value in Rails we could experience @@ -49,7 +67,7 @@ def create_params(settings, params={}) url_string = "SAMLRequest=#{CGI.escape(base64_request)}" url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state url_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - private_key = settings.get_sp_key() + private_key = settings.get_sp_key signature = private_key.sign(XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method]).new, url_string) params['Signature'] = encode(signature) end @@ -61,6 +79,10 @@ def create_params(settings, params={}) request_params end + # Creates the SAMLRequest String. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @return [String] The SAMLRequest String. + # def create_authentication_xml_doc(settings) time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -118,8 +140,8 @@ def create_authentication_xml_doc(settings) # embed signature if settings.security[:authn_requests_signed] && settings.private_key && settings.certificate && settings.security[:embed_sign] - private_key = settings.get_sp_key() - cert = settings.get_sp_cert() + private_key = settings.get_sp_key + cert = settings.get_sp_cert request_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) end diff --git a/lib/onelogin/ruby-saml/idp_metadata_parser.rb b/lib/onelogin/ruby-saml/idp_metadata_parser.rb index 89be03eb6..f7118e764 100644 --- a/lib/onelogin/ruby-saml/idp_metadata_parser.rb +++ b/lib/onelogin/ruby-saml/idp_metadata_parser.rb @@ -7,10 +7,13 @@ require "rexml/document" require "rexml/xpath" +# Only supports SAML 2.0 module OneLogin module RubySaml include REXML + # Auxiliary class to retrieve and parse the Identity Provider Metadata + # class IdpMetadataParser METADATA = "urn:oasis:names:tc:SAML:2.0:metadata" @@ -18,11 +21,18 @@ class IdpMetadataParser attr_reader :document + # Parse the Identity Provider metadata and update the settings with the IdP values + # @param url [String] Url where the XML of the Identity Provider Metadata is published. + # @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked. + # def parse_remote(url, validate_cert = true) idp_metadata = get_idp_metadata(url, validate_cert) parse(idp_metadata) end + # Parse the Identity Provider metadata and update the settings with the IdP values + # @param idp_metadata [String] + # def parse(idp_metadata) @document = REXML::Document.new(idp_metadata) @@ -37,8 +47,11 @@ def parse(idp_metadata) private - # Retrieve the remote IdP metadata from the URL or a cached copy - # # returns a REXML document of the metadata + # Retrieve the remote IdP metadata from the URL or a cached copy. + # @param url [String] Url where the XML of the Identity Provider Metadata is published. + # @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked. + # @return [REXML::document] Parsed XML IdP metadata + # def get_idp_metadata(url, validate_cert) uri = URI.parse(url) if uri.scheme == "http" @@ -66,33 +79,65 @@ def get_idp_metadata(url, validate_cert) meta_text end + # @return [String|nil] IdP Entity ID value if exists + # def idp_entity_id - node = REXML::XPath.first(document, "/md:EntityDescriptor/@entityID", { "md" => METADATA }) + node = REXML::XPath.first( + document, + "/md:EntityDescriptor/@entityID", + { "md" => METADATA } + ) node.value if node end + # @return [String|nil] IdP Name ID Format value if exists + # def idp_name_id_format - node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:NameIDFormat", { "md" => METADATA }) + node = REXML::XPath.first( + document, + "/md:EntityDescriptor/md:IDPSSODescriptor/md:NameIDFormat", + { "md" => METADATA } + ) node.text if node end + # @return [String|nil] SingleSignOnService endpoint if exists + # def single_signon_service_url - node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleSignOnService/@Location", { "md" => METADATA }) + node = REXML::XPath.first( + document, + "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleSignOnService/@Location", + { "md" => METADATA } + ) node.value if node end + # @return [String|nil] SingleLogoutService endpoint if exists + # def single_logout_service_url - node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleLogoutService/@Location", { "md" => METADATA }) + node = REXML::XPath.first( + document, + "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleLogoutService/@Location", + { "md" => METADATA } + ) node.value if node end + # @return [String|nil] X509Certificate if exists + # def certificate @certificate ||= begin - node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate", { "md" => METADATA, "ds" => DSIG }) + node = REXML::XPath.first( + document, + "/md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate", + { "md" => METADATA, "ds" => DSIG } + ) Base64.decode64(node.text) if node end end + # @return [String|nil] the SHA-1 fingerpint of the X509Certificate if it exists + # def fingerprint @fingerprint ||= begin if certificate diff --git a/lib/onelogin/ruby-saml/logoutrequest.rb b/lib/onelogin/ruby-saml/logoutrequest.rb index c7c2104c4..671e18eac 100644 --- a/lib/onelogin/ruby-saml/logoutrequest.rb +++ b/lib/onelogin/ruby-saml/logoutrequest.rb @@ -3,16 +3,29 @@ require "onelogin/ruby-saml/logging" require "onelogin/ruby-saml/saml_message" +# Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Logout Request (SLO SP initiated, Builder) + # class Logoutrequest < SamlMessage - attr_reader :uuid # Can be obtained if neccessary + # Logout Request ID + attr_reader :uuid + # Initializes the Logout Request. A Logoutrequest Object that is an extension of the SamlMessage class. + # Asigns an ID, a random uuid. + # def initialize @uuid = "_" + UUID.new.generate end + # Creates the Logout Request string. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [String] Logout Request string that includes the SAMLRequest + # def create(settings, params={}) params = create_params(settings, params) params_prefix = (settings.idp_slo_target_url =~ /\?/) ? '&' : '?' @@ -24,6 +37,11 @@ def create(settings, params={}) @logout_url = settings.idp_slo_target_url + request_params end + # Creates the Get parameters for the logout request. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [Hash] Parameters + # def create_params(settings, params={}) # The method expects :RelayState but sometimes we get 'RelayState' instead. # Based on the HashWithIndifferentAccess value in Rails we could experience @@ -47,7 +65,7 @@ def create_params(settings, params={}) url_string = "SAMLRequest=#{CGI.escape(base64_request)}" url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state url_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - private_key = settings.get_sp_key() + private_key = settings.get_sp_key signature = private_key.sign(XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method]).new, url_string) params['Signature'] = encode(signature) end @@ -59,6 +77,10 @@ def create_params(settings, params={}) request_params end + # Creates the SAMLRequest String. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @return [String] The SAMLRequest String. + # def create_logout_request_xml_doc(settings) time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -94,8 +116,8 @@ def create_logout_request_xml_doc(settings) # embed signature if settings.security[:logout_requests_signed] && settings.private_key && settings.certificate && settings.security[:embed_sign] - private_key = settings.get_sp_key() - cert = settings.get_sp_cert() + private_key = settings.get_sp_key + cert = settings.get_sp_cert request_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) end diff --git a/lib/onelogin/ruby-saml/logoutresponse.rb b/lib/onelogin/ruby-saml/logoutresponse.rb index 0c5ddf337..286d471cc 100644 --- a/lib/onelogin/ruby-saml/logoutresponse.rb +++ b/lib/onelogin/ruby-saml/logoutresponse.rb @@ -3,23 +3,27 @@ require "time" +# Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Logout Response (SLO IdP initiated, Parser) + # class Logoutresponse < SamlMessage - # For API compability, this is mutable. + + # OneLogin::RubySaml::Settings Toolkit settings attr_accessor :settings attr_reader :document attr_reader :response attr_reader :options - # - # In order to validate that the response matches a given request, append - # the option: - # :matches_request_id => REQUEST_ID - # - # It will validate that the logout response matches the ID of the request. - # You can also do this yourself through the in_response_to accessor. + # Constructs the Logout Response. A Logout Response Object that is an extension of the SamlMessage class. + # @param response [String] A UUEncoded logout response from the IdP. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param options [Hash] Extra parameters. + # :matches_request_id It will validate that the logout response matches the ID of the request. + # @raise [ArgumentError] if response is nil # def initialize(response, settings = nil, options = {}) raise ArgumentError.new("Logoutresponse cannot be nil") if response.nil? @@ -30,16 +34,29 @@ def initialize(response, settings = nil, options = {}) @document = XMLSecurity::SignedDocument.new(@response) end + # Hard aux function to validate the Logout Response (soft = false) + # @return [Boolean] TRUE if the SAML Response is valid + # @raise [ValidationError] If validation fails + # def validate! validate(false) end + # Aux function to validate the Logout Response + # @return [Boolean] TRUE if the SAML Response is valid + # @raise [ValidationError] if soft == false and validation fails + # def validate(soft = true) return false unless valid_saml?(document, soft) && valid_state?(soft) valid_in_response_to?(soft) && valid_issuer?(soft) && success?(soft) end + # Checks if the Status has the "Success" code + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the logout response is invalid or not) + # @return [Boolean] True if the StatusCode is Sucess + # @raise [ValidationError] if soft == false and validation fails + # def success?(soft = true) unless status_code == "urn:oasis:names:tc:SAML:2.0:status:Success" return soft ? false : validation_error("Bad status code. Expected , but was: <#@status_code> ") @@ -47,6 +64,8 @@ def success?(soft = true) true end + # @return [String|nil] Gets the InResponseTo attribute from the Logout Response if exists. + # def in_response_to @in_response_to ||= begin node = REXML::XPath.first(document, "/p:LogoutResponse", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -54,6 +73,8 @@ def in_response_to end end + # @return [String] Gets the Issuer from the Logout Response. + # def issuer @issuer ||= begin node = REXML::XPath.first(document, "/p:LogoutResponse/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -62,6 +83,8 @@ def issuer end end + # @return [String] Gets the StatusCode from a Logout Response. + # def status_code @status_code ||= begin node = REXML::XPath.first(document, "/p:LogoutResponse/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -71,6 +94,12 @@ def status_code private + # Validates that the Logout Response provided in the initialization is not empty, + # also check that the setting and the IdP cert were also provided + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the logout response is invalid or not) + # @return [Boolean] True if the required info is found, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def valid_state?(soft = true) if response.empty? return soft ? false : validation_error("Blank response") @@ -91,6 +120,11 @@ def valid_state?(soft = true) true end + # Validates if a provided :matches_request_id matchs the inResponseTo value. + # @param soft [String|nil] request_id The ID of the Logout Request sent by this SP to the IdP (if was sent any) + # @return [Boolean] True if there is no request_id or it match, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def valid_in_response_to?(soft = true) return true unless self.options.has_key? :matches_request_id @@ -101,6 +135,11 @@ def valid_in_response_to?(soft = true) true end + # Validates the Issuer of the Logout Response + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the logout response is invalid or not) + # @return [Boolean] True if the Issuer matchs the IdP entityId, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def valid_issuer?(soft = true) return true if self.settings.idp_entity_id.nil? or self.issuer.nil? diff --git a/lib/onelogin/ruby-saml/metadata.rb b/lib/onelogin/ruby-saml/metadata.rb index ac1821221..60923e82a 100644 --- a/lib/onelogin/ruby-saml/metadata.rb +++ b/lib/onelogin/ruby-saml/metadata.rb @@ -3,13 +3,20 @@ require "onelogin/ruby-saml/logging" -# Class to return SP metadata based on the settings requested. -# Return this XML in a controller, then give that URL to the the -# IdP administrator. The IdP will poll the URL and your settings -# will be updated automatically +# Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Metadata. XML Metadata Builder + # class Metadata + + # Return SP metadata based on the settings. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param pretty_print [Boolean] Pretty print or not the response + # (No pretty print if you gonna validate the signature) + # @return [String] XML Metadata of the Service Provider + # def generate(settings, pretty_print=true) meta_doc = XMLSecurity::Document.new root = meta_doc.add_element "md:EntityDescriptor", { @@ -48,7 +55,7 @@ def generate(settings, pretty_print=true) end # Add KeyDescriptor if messages will be signed - cert = settings.get_sp_cert() + cert = settings.get_sp_cert if cert kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" } ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"} @@ -87,7 +94,7 @@ def generate(settings, pretty_print=true) # embed signature if settings.security[:metadata_signed] && settings.private_key && settings.certificate - private_key = settings.get_sp_key() + private_key = settings.get_sp_key meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) end diff --git a/lib/onelogin/ruby-saml/response.rb b/lib/onelogin/ruby-saml/response.rb index 45a26e08a..c4cc7b7db 100644 --- a/lib/onelogin/ruby-saml/response.rb +++ b/lib/onelogin/ruby-saml/response.rb @@ -8,40 +8,58 @@ module OneLogin module RubySaml + # SAML2 Authentication Response. SAML Response + # class Response < SamlMessage ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" DSIG = "http://www.w3.org/2000/09/xmldsig#" - # TODO: This should probably be ctor initialized too... WDYT? + # TODO: Settings should probably be initialized too... WDYT? + + # OneLogin::RubySaml::Settings Toolkit settings attr_accessor :settings + + # Array with the causes [Array of strings] attr_accessor :errors attr_reader :options attr_reader :response attr_reader :document + # Constructs the SAML Response. A Response Object that is an extension of the SamlMessage class. + # @param response [String] A UUEncoded SAML response from the IdP. + # @param options [Hash] Some options for the response validation process like skip the conditions validation + # with the :skip_conditions, or allow a clock_drift when checking dates with :allowed_clock_drift + # def initialize(response, options = {}) @errors = [] + raise ArgumentError.new("Response cannot be nil") if response.nil? @options = options @response = decode_raw_saml(response) @document = XMLSecurity::SignedDocument.new(@response, @errors) end + # Validates the SAML Response with the default values (soft = true) + # @return [Boolean] TRUE if the SAML Response is valid + # def is_valid? validate end + # Hard aux function to validate the SAML Response (soft = false) + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @param request_id [String|nil] request_id The ID of the AuthNRequest sent by this SP to the IdP (if was sent any) + # @return [Boolean] TRUE if the SAML Response is valid + # @raise [ValidationError] if soft == false and validation fails + # def validate! validate(false) end - def errors - @errors - end - - # The value of the user identifier as designated by the initialization request response + # @return [String] the NameID provided by the SAML response from the IdP. + # def name_id @name_id ||= begin node = xpath_first_from_signed_assertion('/a:Subject/a:NameID') @@ -49,6 +67,12 @@ def name_id end end + # Gets the SessionIndex from the AuthnStatement. + # Could be used to be stored in the local session in order + # to be used in a future Logout Request that the SP could + # send to the IdP, to set what specific session must be deleted + # @return [String] SessionIndex Value + # def sessionindex @sessionindex ||= begin node = xpath_first_from_signed_assertion('/a:AuthnStatement') @@ -56,9 +80,9 @@ def sessionindex end end - # Returns OneLogin::RubySaml::Attributes enumerable collection. - # All attributes can be iterated over +attributes.each+ or returned as array by +attributes.all+ + # Gets the Attributes from the AttributeStatement element. # + # All attributes can be iterated over +attributes.each+ or returned as array by +attributes.all+ # For backwards compatibility ruby-saml returns by default only the first value for a given attribute with # attributes['name'] # To get all of the attributes, use: @@ -67,6 +91,9 @@ def sessionindex # OneLogin::RubySaml::Attributes.single_value_compatibility = false # Now this will return an array: # attributes['name'] + # + # @return [Attributes] OneLogin::RubySaml::Attributes enumerable collection. + # def attributes @attr_statements ||= begin attributes = Attributes.new @@ -89,7 +116,10 @@ def attributes end end - # When this user session should expire at latest + # Gets the SessionNotOnOrAfter from the AuthnStatement. + # Could be used to set the local session expiration (expire at latest) + # @return [String] The SessionNotOnOrAfter value + # def session_expires_at @expires_at ||= begin node = xpath_first_from_signed_assertion('/a:AuthnStatement') @@ -97,7 +127,9 @@ def session_expires_at end end - # Checks the status of the response for a "Success" code + # Checks if the Status has the "Success" code + # @return [Boolean] True if the StatusCode is Sucess + # def success? @status_code ||= begin node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -105,6 +137,8 @@ def success? end end + # @return [String] the StatusMessage value from a SAML Response. + # def status_message @status_message ||= begin node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusMessage", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -112,19 +146,31 @@ def status_message end end - # Conditions (if any) for the assertion to run + # Gets the Condition Element of the SAML Response if exists. + # (returns the first node that matches the supplied xpath) + # @return [REXML::Element] Conditions Element if exists + # def conditions @conditions ||= xpath_first_from_signed_assertion('/a:Conditions') end + # Gets the NotBefore Condition Element value. + # @return [Time] The NotBefore value in Time format + # def not_before @not_before ||= parse_time(conditions, "NotBefore") end + # Gets the NotOnOrAfter Condition Element value. + # @return [Time] The NotOnOrAfter value in Time format + # def not_on_or_after @not_on_or_after ||= parse_time(conditions, "NotOnOrAfter") end + # Gets the Issuer from the Response or the Assertion. + # @return [String] The first Issuer found. First check Response, later the Assertion. + # def issuer @issuer ||= begin node = REXML::XPath.first(document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -135,6 +181,11 @@ def issuer private + # Validates the SAML Response (calls several validation methods) + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @return [Boolean] True if the SAML Response is valid, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def validate(soft = true) valid_saml?(document, soft) && validate_response_state(soft) && @@ -144,6 +195,11 @@ def validate(soft = true) validate_success_status(soft) end + # Validates the Status of the SAML Response + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @return [Boolean] True if the SAML Response contains a Success code, otherwise False if soft == false + # @raise [ValidationError] if soft == false and validation fails + # def validate_success_status(soft = true) if success? true @@ -152,6 +208,11 @@ def validate_success_status(soft = true) end end + # Validates the SAML Response against the specified schema. + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @return [Boolean] True if the XML is valid, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def validate_structure(soft = true) xml = Nokogiri::XML(self.document.to_s) @@ -168,6 +229,12 @@ def validate_structure(soft = true) end end + # Validates that the SAML Response provided in the initialization is not empty, + # also check that the setting and the IdP cert were also provided + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @return [Boolean] True if the required info is found, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def validate_response_state(soft = true) if response.empty? return soft ? false : validation_error("Blank response") @@ -184,6 +251,11 @@ def validate_response_state(soft = true) true end + # Extracts the first appearance that matchs the subelt (pattern) + # Search on any Assertion that is signed, or has a Response parent signed + # @param subelt [String] The XPath pattern + # @return [REXML::Element | nil] If any matches, return the Element + # def xpath_first_from_signed_assertion(subelt=nil) node = REXML::XPath.first( document, @@ -200,6 +272,9 @@ def xpath_first_from_signed_assertion(subelt=nil) node end + # Calculates the fingerprint of the IdP x509 certificate. + # @return [String] The fingerprint + # def get_fingerprint if settings.idp_cert cert = OpenSSL::X509::Certificate.new(settings.idp_cert) @@ -210,6 +285,12 @@ def get_fingerprint end end + # Validates the Conditions. (If the response was initialized with the :skip_conditions option, this validation is skipped, + # If the response was initialized with the :allowed_clock_drift option, the timing validations are relaxed by the allowed_clock_drift value) + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @return [Boolean] True if satisfies the conditions, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def validate_conditions(soft = true) return true if conditions.nil? return true if options[:skip_conditions] @@ -229,6 +310,11 @@ def validate_conditions(soft = true) true end + # Validates the Issuer (Of the SAML Response or of the SAML Assertion) + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not) + # @return [Boolean] True if the Issuer matchs the IdP entityId, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def validate_issuer(soft = true) return true if settings.idp_entity_id.nil? @@ -238,6 +324,11 @@ def validate_issuer(soft = true) true end + # Parse the attribute of a given node in Time format + # @param node [REXML:Element] The node + # @param attribute [String] The attribute name + # @return [Time|nil] The parsed value + # def parse_time(node, attribute) if node && node.attributes[attribute] Time.parse(node.attributes[attribute]) diff --git a/lib/onelogin/ruby-saml/saml_message.rb b/lib/onelogin/ruby-saml/saml_message.rb index b2c8fcf3e..6b31dff50 100644 --- a/lib/onelogin/ruby-saml/saml_message.rb +++ b/lib/onelogin/ruby-saml/saml_message.rb @@ -6,8 +6,12 @@ require 'rexml/xpath' require 'thread' +# Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Message + # class SamlMessage include REXML @@ -16,6 +20,8 @@ class SamlMessage BASE64_FORMAT = %r(\A[A-Za-z0-9+/]{4}*[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=?\Z) + # @return [Nokogiri::XML::Schema] Gets the schema object of the SAML 2.0 Protocol schema + # def self.schema @schema ||= Mutex.new.synchronize do Dir.chdir(File.expand_path("../../../schemas", __FILE__)) do @@ -24,6 +30,12 @@ def self.schema end end + # Validates the SAML Message against the specified schema. + # @param document [REXML::Document] The message that will be validated + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the message is invalid or not) + # @return [Boolean] True if the XML is valid, otherwise False, if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def valid_saml?(document, soft = true) xml = Nokogiri::XML(document.to_s) @@ -33,20 +45,20 @@ def valid_saml?(document, soft = true) end end + # Raise a ValidationError with the provided message + # @param message [String] Message of the exception + # @raise [ValidationError] + # def validation_error(message) raise ValidationError.new(message) end private - ## - # Take a SAML object provided by +saml+, determine its status and return - # a decoded XML as a String. + # Base64 decode and try also to inflate a SAML Message + # @param saml [String] The deflated and encoded SAML Message + # @return [String] The plain SAML Message # - # Since SAML decided to use the RFC1951 and therefor has no zlib markers, - # the only reliable method of deciding whether we have a zlib stream or not - # is to try and inflate it and fall back to the base64 decoded string if - # the stream contains errors. def decode_raw_saml(saml) return saml unless base64_encoded?(saml) @@ -58,32 +70,53 @@ def decode_raw_saml(saml) end end + # Deflate, base64 encode and url-encode a SAML Message (To be used in the HTTP-redirect binding) + # @param saml [String] The plain SAML Message + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @return [String] The deflated and encoded SAML Message (encoded if the compression is requested) + # def encode_raw_saml(saml, settings) saml = deflate(saml) if settings.compress_request CGI.escape(Base64.encode64(saml)) end - def decode(encoded) - Base64.decode64(encoded) + # Base 64 decode method + # @param string [String] The string message + # @return [String] The decoded string + # + def decode(string) + Base64.decode64(string) end - def encode(encoded) - Base64.encode64(encoded).gsub(/\n/, "") + # Base 64 encode method + # @param string [String] The string + # @return [String] The encoded string + # + def encode(string) + Base64.encode64(string).gsub(/\n/, "") end # Check if a string is base64 encoded - # # @param string [String] string to check the encoding of # @return [true, false] whether or not the string is base64 encoded + # def base64_encoded?(string) !!string.gsub(/[\r\n]|\\r|\\n/, "").match(BASE64_FORMAT) end + # Inflate method + # @param deflated [String] The string + # @return [String] The inflated string + # def inflate(deflated) Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(deflated) end + # Deflate method + # @param inflated [String] The string + # @return [String] The deflated string + # def deflate(inflated) Zlib::Deflate.deflate(inflated, 9)[2..-5] end diff --git a/lib/onelogin/ruby-saml/settings.rb b/lib/onelogin/ruby-saml/settings.rb index f6b4e27a6..7a4370091 100644 --- a/lib/onelogin/ruby-saml/settings.rb +++ b/lib/onelogin/ruby-saml/settings.rb @@ -2,8 +2,12 @@ require "onelogin/ruby-saml/attribute_service" require "onelogin/ruby-saml/utils" +# Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Toolkit Settings + # class Settings def initialize(overrides = {}) config = DEFAULTS.merge(overrides) @@ -50,7 +54,9 @@ def initialize(overrides = {}) attr_accessor :assertion_consumer_logout_service_url attr_accessor :assertion_consumer_logout_service_binding - def single_logout_service_url() + # @return [String] Single Logout Service URL. + # + def single_logout_service_url val = nil if @single_logout_service_url.nil? if @assertion_consumer_logout_service_url @@ -62,12 +68,16 @@ def single_logout_service_url() val end - # setter - def single_logout_service_url=(val) - @single_logout_service_url = val + # Setter for the Single Logout Service URL. + # @param url [String]. + # + def single_logout_service_url=(url) + @single_logout_service_url = url end - def single_logout_service_binding() + # @return [String] Single Logout Service Binding. + # + def single_logout_service_binding val = nil if @single_logout_service_binding.nil? if @assertion_consumer_logout_service_binding @@ -79,11 +89,17 @@ def single_logout_service_binding() val end - # setter - def single_logout_service_binding=(val) - @single_logout_service_binding = val + # Setter for Single Logout Service Binding. + # + # (Currently we only support "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect") + # @param url [String] + # + def single_logout_service_binding=(url) + @single_logout_service_binding = url end + # @return [OpenSSL::X509::Certificate|nil] Build the certificate from the Formatted SP certificate of the settings + # def get_sp_cert cert = nil if self.certificate @@ -93,6 +109,8 @@ def get_sp_cert cert end + # @return [OpenSSL::PKey::RSA] Build the private key from the Formatted SP private key of the settings + # def get_sp_key private_key = nil if self.private_key diff --git a/lib/onelogin/ruby-saml/slo_logoutrequest.rb b/lib/onelogin/ruby-saml/slo_logoutrequest.rb index 2cbb88540..4fcdc0853 100644 --- a/lib/onelogin/ruby-saml/slo_logoutrequest.rb +++ b/lib/onelogin/ruby-saml/slo_logoutrequest.rb @@ -7,11 +7,19 @@ # Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Logout Request (SLO IdP initiated, Parser) + # class SloLogoutrequest < SamlMessage attr_reader :options attr_reader :request attr_reader :document + # Constructs the Logout Request. A Logout Request Object that is an extension of the SamlMessage class. + # @param request [String] A UUEncoded Logout Request from the IdP. + # @param options [Hash] Some options for the logout request validation process like allow a clock drift when checking dates with :allowed_clock_drift + # @raise [ArgumentError] If Request is nil + # def initialize(request, options = {}) raise ArgumentError.new("Request cannot be nil") if request.nil? @options = options @@ -19,15 +27,23 @@ def initialize(request, options = {}) @document = REXML::Document.new(@request) end + # Validates the Logout Request with the default values (soft = true) + # @return [Boolean] TRUE if the Logout Request is valid + # def is_valid? validate end + # Validates the Logout Request (soft = false) + # @return [Boolean] TRUE if the Logout Request is valid + # @raise [ValidationError] if validation fails + # def validate! validate(false) end - # The value of the user identifier as designated by the initialization request response + # @return [String] Gets the NameID of the Logout Request. + # def name_id @name_id ||= begin node = REXML::XPath.first(document, "/p:LogoutRequest/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -35,6 +51,8 @@ def name_id end end + # @return [String|nil] Gets the ID attribute from the Logout Request if exists. + # def id return @id if @id element = REXML::XPath.first(document, "/p:LogoutRequest", { @@ -43,6 +61,8 @@ def id return element.attributes["ID"] end + # @return [String] Gets the Issuer from the Logout Request. + # def issuer @issuer ||= begin node = REXML::XPath.first(document, "/p:LogoutRequest/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION }) @@ -52,10 +72,20 @@ def issuer private + # Hard aux function to validate the Logout Request + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the logout request is invalid or not) + # @return [Boolean] TRUE if the Logout Request is valid + # @raise [ValidationError] if soft == false and validation fails + # def validate(soft = true) valid_saml?(document, soft) && validate_request_state(soft) end + # Validates that the Logout Request provided in the initialization is not empty, + # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the logout request is invalid or not) + # @return [Boolean] True if the required info is found, otherwise False if soft=True + # @raise [ValidationError] if soft == false and validation fails + # def validate_request_state(soft = true) if request.empty? return soft ? false : validation_error("Blank request") diff --git a/lib/onelogin/ruby-saml/slo_logoutresponse.rb b/lib/onelogin/ruby-saml/slo_logoutresponse.rb index cd71e281c..d1abf8ea1 100644 --- a/lib/onelogin/ruby-saml/slo_logoutresponse.rb +++ b/lib/onelogin/ruby-saml/slo_logoutresponse.rb @@ -3,16 +3,31 @@ require "onelogin/ruby-saml/logging" require "onelogin/ruby-saml/saml_message" +# Only supports SAML 2.0 module OneLogin module RubySaml + + # SAML2 Logout Response (SLO SP initiated, Parser) + # class SloLogoutresponse < SamlMessage - attr_reader :uuid # Can be obtained if neccessary + # Logout Response ID + attr_reader :uuid + # Initializes the Logout Response. A SloLogoutresponse Object that is an extension of the SamlMessage class. + # Asigns an ID, a random uuid. + # def initialize @uuid = "_" + UUID.new.generate end + # Creates the Logout Response string. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param request_id [String] The ID of the LogoutRequest sent by this SP to the IdP. That ID will be placed as the InResponseTo in the logout response + # @param logout_message [String] The Message to be placed as StatusMessage in the logout response + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [String] Logout Request string that includes the SAMLRequest + # def create(settings, request_id = nil, logout_message = nil, params = {}) params = create_params(settings, request_id, logout_message, params) params_prefix = (settings.idp_slo_target_url =~ /\?/) ? '&' : '?' @@ -25,6 +40,13 @@ def create(settings, request_id = nil, logout_message = nil, params = {}) @logout_url = settings.idp_slo_target_url + response_params end + # Creates the Get parameters for the logout response. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param request_id [String] The ID of the LogoutRequest sent by this SP to the IdP. That ID will be placed as the InResponseTo in the logout response + # @param logout_message [String] The Message to be placed as StatusMessage in the logout response + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [Hash] Parameters + # def create_params(settings, request_id = nil, logout_message = nil, params = {}) # The method expects :RelayState but sometimes we get 'RelayState' instead. # Based on the HashWithIndifferentAccess value in Rails we could experience @@ -48,7 +70,7 @@ def create_params(settings, request_id = nil, logout_message = nil, params = {}) url_string = "SAMLResponse=#{CGI.escape(base64_response)}" url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state url_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" - private_key = settings.get_sp_key() + private_key = settings.get_sp_key signature = private_key.sign(XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method]).new, url_string) params['Signature'] = encode(signature) end @@ -60,6 +82,12 @@ def create_params(settings, request_id = nil, logout_message = nil, params = {}) response_params end + # Creates the SAMLResponse String. + # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings + # @param request_id [String] The ID of the LogoutRequest sent by this SP to the IdP. That ID will be placed as the InResponseTo in the logout response + # @param logout_message [String] The Message to be placed as StatusMessage in the logout response + # @return [String] The SAMLResponse String. + # def create_logout_response_xml_doc(settings, request_id = nil, logout_message = nil) time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') @@ -92,8 +120,8 @@ def create_logout_response_xml_doc(settings, request_id = nil, logout_message = # embed signature if settings.security[:logout_responses_signed] && settings.private_key && settings.certificate && settings.security[:embed_sign] - private_key = settings.get_sp_key() - cert = settings.get_sp_cert() + private_key = settings.get_sp_key + cert = settings.get_sp_cert response_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method]) end diff --git a/lib/onelogin/ruby-saml/utils.rb b/lib/onelogin/ruby-saml/utils.rb index 1b0728ad5..374de1b4d 100644 --- a/lib/onelogin/ruby-saml/utils.rb +++ b/lib/onelogin/ruby-saml/utils.rb @@ -1,42 +1,59 @@ module OneLogin module RubySaml + + # SAML2 Auxiliary class + # class Utils + + # Return the x509 certificate string formatted + # @param cert [String] The original certificate + # @param heads [Boolean] If true, the formatted certificate will include the + # "BEGIN CERTIFICATE" header and the footer. + # @return [String] The formatted certificate + # def self.format_cert(cert, heads=true) - cert = cert.delete("\n").delete("\r").delete("\x0D") - if cert - cert = cert.gsub('-----BEGIN CERTIFICATE-----', '') - cert = cert.gsub('-----END CERTIFICATE-----', '') - cert = cert.gsub(' ', '') + if cert && !cert.empty? + cert = cert.gsub(/\-{5}\s?(BEGIN|END) CERTIFICATE\s?\-{5}/, "") + cert = cert.gsub(/[\n\r\s]/, "") + cert = cert.scan(/.{1,64}/).join("\n")+"\n" if heads - cert = cert.scan(/.{1,64}/).join("\n")+"\n" cert = "-----BEGIN CERTIFICATE-----\n" + cert + "-----END CERTIFICATE-----\n" end end cert end + # Return the private key string formatted + # @param key [String] The original private key + # @param heads [Boolean] If true, the formatted private key will include the + # "BEGIN PRIVATE KEY" or the "BEGIN RSA PRIVATE KEY" header and the footer. + # @return [String] The formatted certificate + # def self.format_private_key(key, heads=true) - key = key.delete("\n").delete("\r").delete("\x0D") - if key + if key && !key.empty? + key = key.delete!("\n\r\x0D") if key.index('-----BEGIN PRIVATE KEY-----') != nil key = key.gsub('-----BEGIN PRIVATE KEY-----', '') key = key.gsub('-----END PRIVATE KEY-----', '') key = key.gsub(' ', '') + + key = key.scan(/.{1,64}/).join("\n")+"\n" if heads - key = key.scan(/.{1,64}/).join("\n")+"\n" key = "-----BEGIN PRIVATE KEY-----\n" + key + "-----END PRIVATE KEY-----\n" end else key = key.gsub('-----BEGIN RSA PRIVATE KEY-----', '') key = key.gsub('-----END RSA PRIVATE KEY-----', '') key = key.gsub(' ', '') + + key = key.scan(/.{1,64}/).join("\n")+"\n" if heads - key = key.scan(/.{1,64}/).join("\n")+"\n" key = "-----BEGIN RSA PRIVATE KEY-----\n" + key + "-----END RSA PRIVATE KEY-----\n" end end end + key end end diff --git a/lib/xml_security.rb b/lib/xml_security.rb index 2304fc233..b1867ba92 100644 --- a/lib/xml_security.rb +++ b/lib/xml_security.rb @@ -45,7 +45,6 @@ def canon_algorithm(element) end case algorithm - when "http://www.w3.org/2001/10/xml-exc-c14n#" then Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 when "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" then Nokogiri::XML::XML_C14N_1_0 when "http://www.w3.org/2006/12/xml-c14n11" then Nokogiri::XML::XML_C14N_1_1 else Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 @@ -184,7 +183,11 @@ def initialize(response, errors = []) def validate_document(idp_cert_fingerprint, soft = true, options = {}) # get cert from response - cert_element = REXML::XPath.first(self, "//ds:X509Certificate", { "ds"=>DSIG }) + cert_element = REXML::XPath.first( + self, + "//ds:X509Certificate", + { "ds"=>DSIG } + ) unless cert_element if soft return false @@ -225,30 +228,54 @@ def validate_signature(base64_cert, soft = true) # store and remove signature node @sig_element ||= begin - element = REXML::XPath.first(@working_copy, "//ds:Signature", {"ds"=>DSIG}) + element = REXML::XPath.first( + @working_copy, + "//ds:Signature", + {"ds"=>DSIG} + ) element.remove end # verify signature - signed_info_element = REXML::XPath.first(@sig_element, "//ds:SignedInfo", {"ds"=>DSIG}) + signed_info_element = REXML::XPath.first( + @sig_element, + "//ds:SignedInfo", + {"ds"=>DSIG} + ) noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => DSIG) noko_signed_info_element = noko_sig_element.at_xpath('./ds:SignedInfo', 'ds' => DSIG) - canon_algorithm = canon_algorithm REXML::XPath.first(@sig_element, '//ds:CanonicalizationMethod', 'ds' => DSIG) + canon_algorithm = canon_algorithm REXML::XPath.first( + @sig_element, + '//ds:CanonicalizationMethod', + 'ds' => DSIG + ) canon_string = noko_signed_info_element.canonicalize(canon_algorithm) noko_sig_element.remove # check digests REXML::XPath.each(@sig_element, "//ds:Reference", {"ds"=>DSIG}) do |ref| - uri = ref.attributes.get_attribute("URI").value - - hashed_element = document.at_xpath("//*[@ID='#{uri[1..-1]}']") - canon_algorithm = canon_algorithm REXML::XPath.first(ref, '//ds:CanonicalizationMethod', 'ds' => DSIG) - canon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces) - - digest_algorithm = algorithm(REXML::XPath.first(ref, "//ds:DigestMethod", 'ds' => DSIG)) - - hash = digest_algorithm.digest(canon_hashed_element) - digest_value = Base64.decode64(REXML::XPath.first(ref, "//ds:DigestValue", {"ds"=>DSIG}).text) + uri = ref.attributes.get_attribute("URI").value + + hashed_element = document.at_xpath("//*[@ID=$uri]", nil, { 'uri' => uri[1..-1] }) + canon_algorithm = canon_algorithm REXML::XPath.first( + ref, + '//ds:CanonicalizationMethod', + { "ds" => DSIG } + ) + canon_hashed_element = hashed_element.canonicalize(canon_algorithm, inclusive_namespaces) + + digest_algorithm = algorithm(REXML::XPath.first( + ref, + "//ds:DigestMethod", + { "ds" => DSIG } + )) + hash = digest_algorithm.digest(canon_hashed_element) + encoded_digest_value = REXML::XPath.first( + ref, + "//ds:DigestValue", + { "ds" => DSIG } + ).text + digest_value = Base64.decode64(encoded_digest_value) unless digests_match?(hash, digest_value) @errors << "Digest mismatch" @@ -256,15 +283,25 @@ def validate_signature(base64_cert, soft = true) end end - base64_signature = REXML::XPath.first(@sig_element, "//ds:SignatureValue", {"ds"=>DSIG}).text - signature = Base64.decode64(base64_signature) + base64_signature = REXML::XPath.first( + @sig_element, + "//ds:SignatureValue", + {"ds" => DSIG} + ).text + + signature = Base64.decode64(base64_signature) # get certificate object - cert_text = Base64.decode64(base64_cert) - cert = OpenSSL::X509::Certificate.new(cert_text) + cert_text = Base64.decode64(base64_cert) + cert = OpenSSL::X509::Certificate.new(cert_text) # signature method - signature_algorithm = algorithm(REXML::XPath.first(signed_info_element, "//ds:SignatureMethod", {"ds"=>DSIG})) + sig_alg_value = REXML::XPath.first( + signed_info_element, + "//ds:SignatureMethod", + {"ds"=>DSIG} + ) + signature_algorithm = algorithm(sig_alg_value) unless cert.public_key.verify(signature_algorithm.new, signature, canon_string) @errors << "Key validation error" @@ -281,12 +318,21 @@ def digests_match?(hash, digest_value) end def extract_signed_element_id - reference_element = REXML::XPath.first(self, "//ds:Signature/ds:SignedInfo/ds:Reference", {"ds"=>DSIG}) - self.signed_element_id = reference_element.attribute("URI").value[1..-1] unless reference_element.nil? + reference_element = REXML::XPath.first( + self, + "//ds:Signature/ds:SignedInfo/ds:Reference", + {"ds"=>DSIG} + ) + self.signed_element_id = reference_element.attribute("URI").value[1..-1] unless reference_element.nil? end def extract_inclusive_namespaces - if element = REXML::XPath.first(self, "//ec:InclusiveNamespaces", { "ec" => C14N }) + element = REXML::XPath.first( + self, + "//ec:InclusiveNamespaces", + { "ec" => C14N } + ) + if element prefix_list = element.attributes.get_attribute("PrefixList").value prefix_list.split(" ") else