Skip to content
Browse files

initial commit -- plugin seems to be mostly working

  • Loading branch information...
0 parents commit 72949075c0be1ee9aa418da0bcea4fe341132554 @zuk committed Jan 25, 2011
Showing with 484 additions and 0 deletions.
  1. +20 −0 MIT-LICENSE
  2. +13 −0 README
  3. +23 −0 Rakefile
  4. +1 −0 init.rb
  5. +1 −0 install.rb
  6. +425 −0 lib/rubycas_client_rails.rb
  7. +1 −0 uninstall.rb
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2011 University of Toronto
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
13 README
@@ -0,0 +1,13 @@
+RubyCAS-Client-Rails
+====================
+
+Introduction goes here.
+
+
+Example
+=======
+
+Example goes here.
+
+
+Copyright (c) 2011 [name of plugin creator], released under the MIT license
23 Rakefile
@@ -0,0 +1,23 @@
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+
+desc 'Default: run unit tests.'
+task :default => :test
+
+desc 'Test the rubycas_client_rails plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.libs << 'test'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
+end
+
+desc 'Generate documentation for the rubycas_client_rails plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'rdoc'
+ rdoc.title = 'RubycasClientRails'
+ rdoc.options << '--line-numbers' << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
1 init.rb
@@ -0,0 +1 @@
+# Include hook code here
1 install.rb
@@ -0,0 +1 @@
+# Install hook code here
425 lib/rubycas_client_rails.rb
@@ -0,0 +1,425 @@
+
+require 'casclient'
+
+module RubyCAS
+ class Railtie < Rails::Railtie
+ config.rubycas = ActiveSupport::OrderedOptions.new
+
+ initializer 'rubycas.initialize' do |app|
+ Rails.logger.debug "SETTING UP RUBYCAS"
+ RubyCAS::Filter.setup(config.rubycas)
+ end
+ end
+
+ class Filter
+ cattr_reader :config, :log, :client
+
+ # These are initialized when you call setup.
+ @@client = nil
+ @@log = nil
+ @@fake_user = nil
+ @@fake_extra_attributes = nil
+
+ class << self
+ def setup(config)
+ @@config = config
+ @@config[:logger] = Rails.logger unless @@config[:logger]
+ @@client = CASClient::Client.new(@@config)
+ @@log = @@client.log
+ end
+
+ def filter(controller)
+ raise "Cannot use the CASClient filter because it has not yet been configured." if config.nil?
+
+ if @@fake_user
+ controller.session[client.username_session_key] = @@fake_user
+ controller.session[:casfilteruser] = @@fake_user
+ controller.session[client.extra_attributes_session_key] = @@fake_extra_attributes
+ return true
+ end
+
+
+ last_st = controller.session[:cas_last_valid_ticket]
+
+ if single_sign_out(controller)
+ controller.send(:render, :text => "CAS Single-Sign-Out request intercepted.")
+ return false
+ end
+
+ st = read_ticket(controller)
+
+ is_new_session = true
+
+ if st && last_st &&
+ last_st.ticket == st.ticket &&
+ last_st.service == st.service
+ # warn() rather than info() because we really shouldn't be re-validating the same ticket.
+ # The only situation where this is acceptable is if the user manually does a refresh and
+ # the same ticket happens to be in the URL.
+ log.warn("Re-using previously validated ticket since the ticket id and service are the same.")
+ st = last_st
+ is_new_session = false
+ elsif last_st &&
+ !config[:authenticate_on_every_request] &&
+ controller.session[client.username_session_key]
+ # Re-use the previous ticket if the user already has a local CAS session (i.e. if they were already
+ # previously authenticated for this service). This is to prevent redirection to the CAS server on every
+ # request.
+ #
+ # This behaviour can be disabled (so that every request is routed through the CAS server) by setting
+ # the :authenticate_on_every_request config option to true. However, this is not desirable since
+ # it will almost certainly break POST request, AJAX calls, etc.
+ log.debug "Existing local CAS session detected for #{controller.session[client.username_session_key].inspect}. "+
+ "Previous ticket #{last_st.ticket.inspect} will be re-used."
+ st = last_st
+ is_new_session = false
+ end
+
+ if st
+ client.validate_service_ticket(st) unless st.has_been_validated?
+ vr = st.response
+
+ if st.is_valid?
+ if is_new_session
+ log.info("Ticket #{st.ticket.inspect} for service #{st.service.inspect} belonging to user #{vr.user.inspect} is VALID.")
+ controller.session[client.username_session_key] = vr.user.dup
+ controller.session[client.extra_attributes_session_key] = HashWithIndifferentAccess.new(vr.extra_attributes) if vr.extra_attributes
+
+ if vr.extra_attributes
+ log.debug("Extra user attributes provided along with ticket #{st.ticket.inspect}: #{vr.extra_attributes.inspect}.")
+ end
+
+ # RubyCAS-Client 1.x used :casfilteruser as it's username session key,
+ # so we need to set this here to ensure compatibility with configurations
+ # built around the old client.
+ controller.session[:casfilteruser] = vr.user
+
+ if config[:enable_single_sign_out]
+ f = store_service_session_lookup(st, controller.request.session_options[:id] || controller.session.session_id)
+ log.debug("Wrote service session lookup file to #{f.inspect} with session id #{controller.request.session_options[:id] || controller.session.session_id.inspect}.")
+ end
+ end
+
+ # Store the ticket in the session to avoid re-validating the same service
+ # ticket with the CAS server.
+ controller.session[:cas_last_valid_ticket] = st
+
+ if vr.pgt_iou
+ unless controller.session[:cas_pgt] && controller.session[:cas_pgt].ticket && controller.session[:cas_pgt].iou == vr.pgt_iou
+ log.info("Receipt has a proxy-granting ticket IOU. Attempting to retrieve the proxy-granting ticket...")
+ pgt = client.retrieve_proxy_granting_ticket(vr.pgt_iou)
+
+ if pgt
+ log.debug("Got PGT #{pgt.ticket.inspect} for PGT IOU #{pgt.iou.inspect}. This will be stored in the session.")
+ controller.session[:cas_pgt] = pgt
+ # For backwards compatibility with RubyCAS-Client 1.x configurations...
+ controller.session[:casfilterpgt] = pgt
+ else
+ log.error("Failed to retrieve a PGT for PGT IOU #{vr.pgt_iou}!")
+ end
+ else
+ log.info("PGT is present in session and PGT IOU #{vr.pgt_iou} matches the saved PGT IOU. Not retrieving new PGT.")
+ end
+
+ end
+
+ return true
+ else
+ log.warn("Ticket #{st.ticket.inspect} failed validation -- #{vr.failure_code}: #{vr.failure_message}")
+ unauthorized!(controller, vr)
+ return false
+ end
+ else # no service ticket was present in the request
+ if returning_from_gateway?(controller)
+ log.info "Returning from CAS gateway without authentication."
+
+ # unset, to allow for the next request to be authenticated if necessary
+ controller.session[:cas_sent_to_gateway] = false
+
+ if use_gatewaying?
+ log.info "This CAS client is configured to use gatewaying, so we will permit the user to continue without authentication."
+ controller.session[client.username_session_key] = nil
+ return true
+ else
+ log.warn "The CAS client is NOT configured to allow gatewaying, yet this request was gatewayed. Something is not right!"
+ end
+ end
+
+ unauthorized!(controller)
+ return false
+ end
+ rescue OpenSSL::SSL::SSLError
+ log.error("SSL Error: hostname was not match with the server certificate. You can try to disable the ssl verification with a :force_ssl_verification => false in your configurations file.")
+ unauthorized!(controller)
+ return false
+ end
+
+ # used to allow faking for testing
+ # with cucumber and other tools.
+ # use like
+ # CASClient::Frameworks::Rails::Filter.fake("homer")
+ # you can also fake extra attributes by including a second parameter
+ # CASClient::Frameworks::Rails::Filter.fake("homer", {:roles => ['dad', 'husband']})
+ def fake(username, extra_attributes = nil)
+ @@fake_user = username
+ @@fake_extra_attributes = extra_attributes
+ end
+
+ def use_gatewaying?
+ @@config[:use_gatewaying]
+ end
+
+ # Returns the login URL for the current controller.
+ # Useful when you want to provide a "Login" link in a GatewayFilter'ed
+ # action.
+ def login_url(controller)
+ service_url = read_service_url(controller)
+ url = client.add_service_to_login_url(service_url)
+ log.debug("Generated login url: #{url}")
+ return url
+ end
+
+ # allow controllers to reuse the existing config to auto-login to
+ # the service
+ #
+ # Use this from within a controller. Pass the controller, the
+ # login-credentials and the path that you want the user
+ # resdirected to on success.
+ #
+ # When writing a login-action you must check the return-value of
+ # the response to see if it failed!
+ #
+ # If it worked - you need to redirect the user to the service -
+ # path, because that has the ticket that will *actually* log them
+ # into your system
+ #
+ # example:
+ # def autologin
+ # resp = CASClient::Frameworks::Rails::Filter.login_to_service(self, credentials, dashboard_url)
+ # if resp.is_faiulure?
+ # flash[:error] = 'Login failed'
+ # render :action => 'login'
+ # else
+ # return redirect_to(@resp.service_redirect_url)
+ # end
+ # end
+ def login_to_service(controller, credentials, return_path)
+ resp = @@client.login_to_service(credentials, return_path)
+ if resp.is_failure?
+ log.info("Validation failed for service #{return_path.inspect} reason: '#{resp.failure_message}'")
+ else
+ log.info("Ticket #{resp.ticket.inspect} for service #{return_path.inspect} is VALID.")
+ end
+
+ resp
+ end
+
+ # Clears the given controller's local Rails session, does some local
+ # CAS cleanup, and redirects to the CAS logout page. Additionally, the
+ # <tt>request.referer</tt> value from the <tt>controller</tt> instance
+ # is passed to the CAS server as a 'destination' parameter. This
+ # allows RubyCAS server to provide a follow-up login page allowing
+ # the user to log back in to the service they just logged out from
+ # using a different username and password. Other CAS server
+ # implemenations may use this 'destination' parameter in different
+ # ways.
+ # If given, the optional <tt>service</tt> URL overrides
+ # <tt>request.referer</tt>.
+ def logout(controller, service = nil)
+ referer = service || controller.request.referer
+ st = controller.session[:cas_last_valid_ticket]
+ delete_service_session_lookup(st) if st
+ controller.send(:reset_session)
+ controller.send(:redirect_to, client.logout_url(referer))
+ end
+
+ def unauthorized!(controller, vr = nil)
+ if controller.params[:format] == "xml"
+ if vr
+ controller.send(:render, :xml => "<errors><error>#{vr.failure_message}</error></errors>", :status => 401)
+ else
+ controller.send(:head, 401)
+ end
+ else
+ redirect_to_cas_for_authentication(controller)
+ end
+ end
+
+ def redirect_to_cas_for_authentication(controller)
+ redirect_url = login_url(controller)
+
+ if use_gatewaying?
+ controller.session[:cas_sent_to_gateway] = true
+ redirect_url << "&gateway=true"
+ else
+ controller.session[:cas_sent_to_gateway] = false
+ end
+
+ if controller.session[:previous_redirect_to_cas] &&
+ controller.session[:previous_redirect_to_cas] > (Time.now - 1.second)
+ log.warn("Previous redirect to the CAS server was less than a second ago. The client at #{controller.request.remote_ip.inspect} may be stuck in a redirection loop!")
+ controller.session[:cas_validation_retry_count] ||= 0
+
+ if controller.session[:cas_validation_retry_count] > 3
+ log.error("Redirection loop intercepted. Client at #{controller.request.remote_ip.inspect} will be redirected back to login page and forced to renew authentication.")
+ redirect_url += "&renew=1&redirection_loop_intercepted=1"
+ end
+
+ controller.session[:cas_validation_retry_count] += 1
+ else
+ controller.session[:cas_validation_retry_count] = 0
+ end
+ controller.session[:previous_redirect_to_cas] = Time.now
+
+ log.debug("Redirecting to #{redirect_url.inspect}")
+ controller.send(:redirect_to, redirect_url)
+ end
+
+ private
+ def single_sign_out(controller)
+
+ # Avoid calling raw_post (which may consume the post body) if
+ # this seems to be a file upload
+ if content_type = controller.request.headers["CONTENT_TYPE"] &&
+ content_type =~ %r{^multipart/}
+ return false
+ end
+
+ if controller.request.post? &&
+ controller.params['logoutRequest'] &&
+ controller.params['logoutRequest'] =~
+ %r{^<samlp:LogoutRequest.*?<samlp:SessionIndex>(.*)</samlp:SessionIndex>}m
+ # TODO: Maybe check that the request came from the registered CAS server? Although this might be
+ # pointless since it's easily spoofable...
+ si = $~[1]
+
+ unless config[:enable_single_sign_out]
+ log.warn "Ignoring single-sign-out request for CAS session #{si.inspect} because ssout functionality is not enabled (see the :enable_single_sign_out config option)."
+ return false
+ end
+
+ log.debug "Intercepted single-sign-out request for CAS session #{si.inspect}."
+
+ begin
+ required_sess_store = ActiveRecord::SessionStore
+ current_sess_store = ActionController::Base.session_store
+ rescue NameError
+ # for older versions of Rails (prior to 2.3)
+ required_sess_store = CGI::Session::ActiveRecordStore
+ current_sess_store = ActionController::Base.session_options[:database_manager]
+ end
+
+
+ if current_sess_store == required_sess_store
+ session_id = read_service_session_lookup(si)
+
+ if session_id
+ session = current_sess_store::Session.find_by_session_id(session_id)
+ if session
+ session.destroy
+ log.debug("Destroyed #{session.inspect} for session #{session_id.inspect} corresponding to service ticket #{si.inspect}.")
+ else
+ log.debug("Data for session #{session_id.inspect} was not found. It may have already been cleared by a local CAS logout request.")
+ end
+
+ log.info("Single-sign-out for session #{session_id.inspect} completed successfuly.")
+ else
+ log.warn("Couldn't destroy session with SessionIndex #{si} because no corresponding session id could be looked up.")
+ end
+ else
+ log.error "Cannot process logout request because this Rails application's session store is "+
+ " #{current_sess_store.name.inspect}. Single Sign-Out only works with the "+
+ " #{required_sess_store.name.inspect} session store."
+ end
+
+ # Return true to indicate that a single-sign-out request was detected
+ # and that further processing of the request is unnecessary.
+ return true
+ end
+
+ # This is not a single-sign-out request.
+ return false
+ end
+
+ def read_ticket(controller)
+ # Note that we are now deleting the 'ticket' and 'renew' parameters, since they really
+ # have no business getting passed on to the controller action.
+
+ ticket = controller.params.delete(:ticket)
+
+ return nil unless ticket
+
+ log.debug("Request contains ticket #{ticket.inspect}.")
+
+ if ticket =~ /^PT-/
+ CASClient::ProxyTicket.new(ticket, read_service_url(controller), controller.params.delete(:renew))
+ else
+ CASClient::ServiceTicket.new(ticket, read_service_url(controller), controller.params.delete(:renew))
+ end
+ end
+
+ def returning_from_gateway?(controller)
+ controller.session[:cas_sent_to_gateway]
+ end
+
+ def read_service_url(controller)
+ if config[:service_url]
+ log.debug("Using explicitly set service url: #{config[:service_url]}")
+ return config[:service_url]
+ end
+
+ params = controller.params.dup
+ params.delete(:ticket)
+ service_url = controller.url_for(params)
+ log.debug("Guessed service url: #{service_url.inspect}")
+ return service_url
+ end
+
+ # Creates a file in tmp/sessions linking a SessionTicket
+ # with the local Rails session id. The file is named
+ # cas_sess.<session ticket> and its text contents is the corresponding
+ # Rails session id.
+ # Returns the filename of the lookup file created.
+ def store_service_session_lookup(st, sid)
+ st = st.ticket if st.kind_of? CASClient::ServiceTicket
+ f = File.new(filename_of_service_session_lookup(st), 'w')
+ f.write(sid)
+ f.close
+ return f.path
+ end
+
+ # Returns the local Rails session ID corresponding to the given
+ # ServiceTicket. This is done by reading the contents of the
+ # cas_sess.<session ticket> file created in a prior call to
+ # #store_service_session_lookup.
+ def read_service_session_lookup(st)
+ st = st.ticket if st.kind_of? CASClient::ServiceTicket
+ ssl_filename = filename_of_service_session_lookup(st)
+ return File.exists?(ssl_filename) && IO.read(ssl_filename)
+ end
+
+ # Removes a stored relationship between a ServiceTicket and a local
+ # Rails session id. This should be called when the session is being
+ # closed.
+ #
+ # See #store_service_session_lookup.
+ def delete_service_session_lookup(st)
+ st = st.ticket if st.kind_of? CASClient::ServiceTicket
+ ssl_filename = filename_of_service_session_lookup(st)
+ File.delete(ssl_filename) if File.exists?(ssl_filename)
+ end
+
+ # Returns the path and filename of the service session lookup file.
+ def filename_of_service_session_lookup(st)
+ st = st.ticket if st.kind_of? CASClient::ServiceTicket
+ return "#{RAILS_ROOT}/tmp/sessions/cas_sess.#{st}"
+ end
+ end
+
+ class GatewayFilter < Filter
+ def self.use_gatewaying?
+ return true unless @@config[:use_gatewaying] == false
+ end
+ end
+ end
+end
1 uninstall.rb
@@ -0,0 +1 @@
+# Uninstall hook code here

0 comments on commit 7294907

Please sign in to comment.
Something went wrong with that request. Please try again.