diff options
Diffstat (limited to 'vendor/gems/omniauth-cas3/lib/omniauth/strategies')
3 files changed, 398 insertions, 0 deletions
diff --git a/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3.rb b/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3.rb new file mode 100644 index 00000000000..7271621c564 --- /dev/null +++ b/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3.rb @@ -0,0 +1,222 @@ +require 'omniauth' +require 'addressable/uri' + +module OmniAuth + module Strategies + class CAS3 + include OmniAuth::Strategy + + # Custom Exceptions + class MissingCASTicket < StandardError; end + class InvalidCASTicket < StandardError; end + + autoload :ServiceTicketValidator, 'omniauth/strategies/cas3/service_ticket_validator' + autoload :LogoutRequest, 'omniauth/strategies/cas3/logout_request' + + attr_accessor :raw_info + alias_method :user_info, :raw_info + + option :name, :cas3 # Required property by OmniAuth::Strategy + + option :host, nil + option :port, nil + option :path, nil + option :ssl, true + option :service_validate_url, '/p3/serviceValidate' + option :login_url, '/login' + option :logout_url, '/logout' + option :on_single_sign_out, Proc.new {} + # A Proc or lambda that returns a Hash of additional user info to be + # merged with the info returned by the CAS server. + # + # @param [Object] An instance of OmniAuth::Strategies::CAS for the current request + # @param [String] The user's Service Ticket value + # @param [Hash] The user info for the Service Ticket returned by the CAS server + # + # @return [Hash] Extra user info + option :fetch_raw_info, Proc.new { Hash.new } + # Make all the keys configurable with some defaults set here + option :uid_field, 'user' + option :name_key, 'name' + option :email_key, 'email' + option :nickname_key, 'user' + option :first_name_key, 'first_name' + option :last_name_key, 'last_name' + option :location_key, 'location' + option :image_key, 'image' + option :phone_key, 'phone' + + # As required by https://github.com/intridea/omniauth/wiki/Auth-Hash-Schema + AuthHashSchemaKeys = %w{name email nickname first_name last_name location image phone} + info do + prune!({ + name: raw_info[options[:name_key].to_s], + email: raw_info[options[:email_key].to_s], + nickname: raw_info[options[:nickname_key].to_s], + first_name: raw_info[options[:first_name_key].to_s], + last_name: raw_info[options[:last_name_key].to_s], + location: raw_info[options[:location_key].to_s], + image: raw_info[options[:image_key].to_s], + phone: raw_info[options[:phone_key].to_s] + }) + end + + extra do + prune!( + raw_info.delete_if{ |k,v| AuthHashSchemaKeys.include?(k) } + ) + end + + uid do + raw_info[options[:uid_field].to_s] + end + + credentials do + prune!({ ticket: @ticket }) + end + + def callback_phase + if on_sso_path? + single_sign_out_phase + else + @ticket = request.params['ticket'] + return fail!(:no_ticket, MissingCASTicket.new('No CAS Ticket')) unless @ticket + fetch_raw_info(@ticket) + return fail!(:invalid_ticket, InvalidCASTicket.new('Invalid CAS Ticket')) if raw_info.empty? + super + end + end + + def request_phase + service_url = append_params(callback_url, return_url) + + [ + 302, + { + 'Location' => login_url(service_url), + 'Content-Type' => 'text/plain' + }, + ["You are being redirected to CAS for sign-in."] + ] + end + + def on_sso_path? + request.post? && request.params.has_key?('logoutRequest') + end + + def single_sign_out_phase + logout_request_service.new(self, request).call(options) + end + + # Build a CAS host with protocol and port + # + # + def cas_url + extract_url if options['url'] + validate_cas_setup + @cas_url ||= begin + uri = Addressable::URI.new + uri.host = options.host + uri.scheme = options.ssl ? 'https' : 'http' + uri.port = options.port + uri.path = options.path + uri.to_s + end + end + + def extract_url + url = Addressable::URI.parse(options.delete('url')) + options.merge!( + 'host' => url.host, + 'port' => url.port, + 'path' => url.path, + 'ssl' => url.scheme == 'https' + ) + end + + def validate_cas_setup + if options.host.nil? || options.login_url.nil? + raise ArgumentError.new(":host and :login_url MUST be provided") + end + end + + # Build a service-validation URL from +service+ and +ticket+. + # If +service+ has a ticket param, first remove it. URL-encode + # +service+ and add it and the +ticket+ as paraemters to the + # CAS serviceValidate URL. + # + # @param [String] service the service (a.k.a. return-to) URL + # @param [String] ticket the ticket to validate + # + # @return [String] a URL like `http://cas.mycompany.com/serviceValidate?service=...&ticket=...` + def service_validate_url(service_url, ticket) + service_url = Addressable::URI.parse(service_url) + service_url.query_values = service_url.query_values.tap { |qs| qs.delete('ticket') } + cas_url + append_params(options.service_validate_url, { + service: service_url.to_s, + ticket: ticket + }) + end + + # Build a CAS login URL from +service+. + # + # @param [String] service the service (a.k.a. return-to) URL + # + # @return [String] a URL like `http://cas.mycompany.com/login?service=...` + def login_url(service) + cas_url + append_params(options.login_url, { service: service }) + end + + # Adds URL-escaped +parameters+ to +base+. + # + # @param [String] base the base URL + # @param [String] params the parameters to append to the URL + # + # @return [String] the new joined URL. + def append_params(base, params) + params = params.each { |k,v| v = Rack::Utils.escape(v) } + Addressable::URI.parse(base).tap do |base_uri| + base_uri.query_values = (base_uri.query_values || {}).merge(params) + end.to_s + end + + # Validate the Service Ticket + # @return [Object] the validated Service Ticket + def validate_service_ticket(ticket) + ServiceTicketValidator.new(self, options, callback_url, ticket).call + end + + private + + def fetch_raw_info(ticket) + ticket_user_info = validate_service_ticket(ticket).user_info + custom_user_info = options.fetch_raw_info.call(self, options, ticket, ticket_user_info) + self.raw_info = ticket_user_info.merge(custom_user_info) + end + + # Deletes Hash pairs with `nil` values. + # From https://github.com/mkdynamic/omniauth-facebook/blob/972ed5e3456bcaed7df1f55efd7c05c216c8f48e/lib/omniauth/strategies/facebook.rb#L122-127 + def prune!(hash) + hash.delete_if do |_, value| + prune!(value) if value.is_a?(Hash) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + end + + def return_url + # If the request already has a `url` parameter, then it will already be appended to the callback URL. + if request.params && request.params['url'] + {} + else + { url: request.referer } + end + end + + def logout_request_service + LogoutRequest + end + end + end +end + +OmniAuth.config.add_camelization 'cas3', 'CAS3' diff --git a/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3/logout_request.rb b/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3/logout_request.rb new file mode 100644 index 00000000000..72978227edb --- /dev/null +++ b/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3/logout_request.rb @@ -0,0 +1,73 @@ +module OmniAuth + module Strategies + class CAS3 + class LogoutRequest + def initialize(strategy, request) + @strategy, @request = strategy, request + end + + def call(options = {}) + @options = options + + begin + result = single_sign_out_callback.call(*logout_request) + rescue StandardError => err + return @strategy.fail! :logout_request, err + else + result = [200,{},'OK'] if result == true || result.nil? + ensure + return unless result + + # TODO: Why does ActionPack::Response return [status,headers,body] + # when Rack::Response#new wants [body,status,headers]? Additionally, + # why does Rack::Response differ in argument order from the usual + # Rack-like [status,headers,body] array? + return Rack::Response.new(result[2],result[0],result[1]).finish + end + end + + private + + def logout_request + @logout_request ||= begin + saml = parse_and_ensure_namespaces(@request.params['logoutRequest']) + ns = saml.collect_namespaces + name_id = saml.xpath('//saml:NameID', ns).text + sess_idx = saml.xpath('//samlp:SessionIndex', ns).text + inject_params(name_id:name_id, session_index:sess_idx) + @request + end + end + + def parse_and_ensure_namespaces(logout_request_xml) + doc = Nokogiri.parse(logout_request_xml) + ns = doc.collect_namespaces + if ns.include?('xmlns:samlp') && ns.include?('xmlns:saml') + doc + else + add_namespaces(doc) + end + end + + def add_namespaces(logout_request_doc) + root = logout_request_doc.root + root.add_namespace('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol') + root.add_namespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion\\') + + # In order to add namespaces properly we need to re-parse the document + Nokogiri.parse(logout_request_doc.to_s) + end + + def inject_params(new_params) + new_params.each do |key, val| + @request.update_param(key, val) + end + end + + def single_sign_out_callback + @options[:on_single_sign_out] + end + end + end + end +end diff --git a/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3/service_ticket_validator.rb b/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3/service_ticket_validator.rb new file mode 100644 index 00000000000..4f9a61c5216 --- /dev/null +++ b/vendor/gems/omniauth-cas3/lib/omniauth/strategies/cas3/service_ticket_validator.rb @@ -0,0 +1,103 @@ +require 'net/http' +require 'net/https' +require 'nokogiri' + +module OmniAuth + module Strategies + class CAS3 + class ServiceTicketValidator + VALIDATION_REQUEST_HEADERS = { 'Accept' => '*/*' } + + # Build a validator from a +configuration+, a + # +return_to+ URL, and a +ticket+. + # + # @param [Hash] options the OmniAuth Strategy options + # @param [String] return_to_url the URL of this CAS client service + # @param [String] ticket the service ticket to validate + def initialize(strategy, options, return_to_url, ticket) + @options = options + @uri = URI.parse(strategy.service_validate_url(return_to_url, ticket)) + end + + # Executes a network request to process the CAS Service Response + def call + @response_body = get_service_response_body + @success_body = find_authentication_success(@response_body) + self + end + + # Request validation of the ticket from the CAS server's + # serviceValidate (CAS 2.0) function. + # + # Swallows all XML parsing errors (and returns +nil+ in those cases). + # + # @return [Hash, nil] a user information hash if the response is valid; +nil+ otherwise. + # + # @raise any connection errors encountered. + def user_info + parse_user_info(@success_body) + end + + private + + # turns an `<cas:authenticationSuccess>` node into a Hash; + # returns nil if given nil + def parse_user_info(node) + return nil if node.nil? + {}.tap do |hash| + node.children.each do |e| + node_name = e.name.sub(/^cas:/, '') + unless e.kind_of?(Nokogiri::XML::Text) || node_name == 'proxies' + # There are no child elements + if e.element_children.count == 0 + hash[node_name] = e.content + elsif e.element_children.count + # JASIG style extra attributes + if node_name == 'attributes' + hash.merge!(parse_user_info(e)) + else + hash[node_name] = [] if hash[node_name].nil? + hash[node_name].push(parse_user_info(e)) + end + end + end + end + end + end + + # finds an `<cas:authenticationSuccess>` node in + # a `<cas:serviceResponse>` body if present; returns nil + # if the passed body is nil or if there is no such node. + def find_authentication_success(body) + return nil if body.nil? || body == '' + begin + doc = Nokogiri::XML(body) + begin + doc.xpath('/cas:serviceResponse/cas:authenticationSuccess') + rescue Nokogiri::XML::XPath::SyntaxError + doc.xpath('/serviceResponse/authenticationSuccess') + end + rescue Nokogiri::XML::XPath::SyntaxError + nil + end + end + + # retrieves the `<cas:serviceResponse>` XML from the CAS server + def get_service_response_body + result = '' + http = Net::HTTP.new(@uri.host, @uri.port) + http.use_ssl = @uri.port == 443 || @uri.instance_of?(URI::HTTPS) + if http.use_ssl? + http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @options.disable_ssl_verification? + http.ca_path = @options.ca_path + end + http.start do |c| + response = c.get "#{@uri.path}?#{@uri.query}", VALIDATION_REQUEST_HEADERS.dup + result = response.body + end + result + end + end + end + end +end |