require 'cgi' require 'logger' # these requires are needed when outside of a Rails app context (e.g. in unit tests) require 'rubygems' require 'active_support' require 'action_controller' require File.dirname(File.expand_path(__FILE__))+'/cas' module CAS # The DummyLogger is a class which might pass through to a real Logger # if one is assigned. However, it can gracefully swallow any logging calls # if there is now Logger assigned. class LoggerWrapper def initialize(logger=nil) set_logger(logger) end # Assign the 'real' Logger instance that this dummy instance wraps around. def set_logger(logger) @logger = logger end # log using the appropriate method if we have a logger # if we dont' have a logger, ignore completely. def method_missing(name, *args) if @logger && @logger.respond_to?(name) @logger.send(name, *args) end end end LOGGER = CAS::LoggerWrapper.new # Allows authentication through a CAS server. # The precondition for this filter to work is that you have an # authentication infrastructure. As such, this is for the enterprise # rather than small shops. # # To use CAS::Filter for authentication, add something like this to # your environment: # # CAS::Filter.cas_base_url = "https://cas.company.com # # The filter will try to use the standard CAS page locations based on this URL. # Or you can explicitly specify the individual URLs: # # CAS::Filter.login_url = "https://cas.company.com/login" # CAS::Filter.validate_url = "https://cas.company.com/proxyValidate" # # The filter will also try to automatically figure out your CAS-protected application's # URL (to send the client back after authenticating on the CAS server), but you can # explicitly override: # # CAS::Filter.service_url = "http://www.my-cas-protected-app.com/ # # It is of course possible to use different configurations in development, test # and production by placing the configuration in the appropriate environments file. # # To add CAS protection to a Rails controller: # # before_filter CAS::Filter # # All of the standard Rails filter qualifiers can also be used. For example: # # before_filter CAS::Filter, :only => [:admin, :private] # # By default CAS::Filter saves the logged in user in session[:casfilteruser] but # that name can be changed by setting CAS::Filter.session_username # The username is also available from the request by # # request.username # # This wrapping of the request can be disabled by # # CAS::Filter.wrap_request = false # # Proxying is also possible. Please see the README for examples. # class Filter @@login_url = "https://localhost/login" @@logout_url = nil @@validate_url = "https://localhost/proxyValidate" @@renew = false @@session_username = :casfilteruser @@query_string = {} @@fake = nil @@pgt = nil cattr_accessor :query_string cattr_accessor :login_url, :validate_url, :service_url, :wrap_request, :session_username class_inheritable_accessor :gateway, :renew cattr_accessor :proxy_url, :proxy_callback_url, :proxy_retrieval_url @@authorized_proxies = [] cattr_accessor :authorized_proxies # gatewaying is disabled by default -- use GatewayFilter if you want gatewaying self.gateway = false class << self # Retrieves the Logger used by the filter def logger CAS::LOGGER end # Sets the Logger used by the filter def logger=(val) CAS::LOGGER.set_logger(val) end alias :log :logger alias :log= :logger= # Builds the internal logout URL. The current @@logout_url value will # be used if it is set. Otherwise we will try to figure it out based # on the @@login_url. def create_logout_url if !@@logout_url && @@login_url =~ %r{^(.+?)/[^/]*$} @@logout_url = "#{$1}/logout" end logger.debug "Created logout url: #{@@logout_url}" end # Returns the logout URL for the given controller. # This method calls create_logout_url if no logout url has yet # been created or set. # # Additionally a service URL can be provided and will be attached # to the CAS server logout URL. If not provided, the service URL # will be automatically derived using guess_service(). def logout_url(controller, service = nil) create_logout_url unless @@logout_url url = redirect_url(controller,@@logout_url,service) logger.debug "Logout url is: #{url}" url end # Explicitly sets the logout URL. def logout_url=(url) @@logout_url = url logger.debug "Initialized logout url to: #{url}" end # Sets the base CAS url. The login_url, validate_url, and proxy_url # are automagically built on top of this. def cas_base_url=(url) url.gsub!(/\/$/, '') CAS::Filter.login_url = "#{url}/login" CAS::Filter.validate_url = "#{url}/proxyValidate" CAS::Filter.proxy_url = "#{url}/proxy" logger.debug "Initialized CAS base url to: #{url}" end # Returns the current @@fake value. # This is used for debugging. See fake= and filter_f. def fake @@fake end # Enables or disables the fake filter. # This is used in debugging. # # The argument can have one of the following values: # # :failure :: The fake filter will always fail. # :param :: The fake filter will use the 'username' request param to set # the username. # Proc :: The fake filter will execute the given proc to determine the # username. The current controller will be fed to the Proc as an # argument. # nil :: Disables the fake filter and enables the real filter. def fake=(val) if val.nil? alias :filter :filter_r else alias :filter :filter_f logger.warn "Will use fake filter" end @@fake = val end # This is the fake filter method. It is aliased as 'filter' # when the fake filter is enabled. See fake=. def filter_f(controller) logger.break logger.warn "Using fake CAS filter" username = @@fake if :failure == @@fake return false elsif :param == @@fake username = controller.params['username'] elsif Proc === @@fake username = @@fake.call(controller) end logger.info("The username set by the fake filter is: #{username}") controller.session[@@session_username] = username return true end # This is the real filter method. It is aliased as 'filter' # by default (when the fake filter is disabled). # # The filter method behaves like a standard Rails filter, taking # the current controller as an argument (in order to access the current # request params, the session, etc.). The method returns true # when authentication is successful, false otherwise. Generally, # before returning false the filter will send a HTTP redirect back to the # CAS server. def filter_r(controller) logger.break logger.info("Using real CAS filter in controller: #{controller}") # We store the receipt in the session so that we do not have to fetch it again # if we're asked to validate a ticket that has already been validated. This # saves us from unnecessarily hitting the CAS server. session_receipt = controller.session[:casfilterreceipt] session_ticket = controller.session[:caslastticket] ticket = controller.params[:ticket] is_valid = false if controller.session[:casfiltergateway] log.debug "Coming back from gatewayed request to CAS server..." did_gateway = true controller.session[:casfiltergateway] = false else log.debug "This request is not gatewayed." end if ticket and (!session_ticket or session_ticket != ticket) log.info "A ticket parameter was given in the URI: #{ticket} and "+ (!session_ticket ? "there is no previous ticket for this session" : "the ticket is different than the previous ticket, which was #{session_ticket}") receipt = get_receipt_for_ticket(ticket, controller) if receipt && validate_receipt(receipt) logger.info("Receipt for ticket request #{ticket} is valid, belongs to user #{receipt.user_name}, and will be stored in the session.") controller.session[:casfilterreceipt] = receipt controller.session[:caslastticket] = ticket controller.session[@@session_username] = receipt.user_name if receipt.pgt_iou logger.info("Receipt has a proxy-granting ticket IOU. Attempting to retrieve the proxy-granting ticket...") pgt = retrieve_pgt(receipt) if pgt log.debug("Got PGT #{pgt} for PGT IOU #{receipt.pgt_iou}. This will be stored in the session.") controller.session[:casfilterpgt] = pgt else log.error("Failed to retrieve a PGT for PGT IOU #{receipt.pgt_iou}!") end end is_valid = true else if receipt log.warn "Receipt was invalid for ticket #{ticket}!" else log.warn "get_receipt_for_ticket() for ticket #{ticket} did not return a receipt!" end end elsif session_receipt && controller.session[@@session_username] && !@@renew log.info "Validating receipt from the session (instead of checking with the CAS server) because we have a :casfilteruser and the filter is not configured with @@renew." log.debug "The session receipt is: #{session_receipt}" is_valid = validate_receipt(session_receipt) if is_valid log.info "The session receipt is VALID" else log.warn "The session receipt is NOT VALID!" end else log.info "No ticket was given and we do not have a receipt in the session." raise CASException, "Can't redirect without login url" unless @@login_url if did_gateway log.info "We gatewayed and came back without authentication." if self.gateway log.info "This filter is configured to allow gatewaying, so we will permit the user to continue without authentication." return true else log.warn "This filter is NOT configured to allow gatewaying, yet this request was gatewayed. Something is not right!" end elsif self.gateway log.debug "We did not gateway, so we will notify the filter that the next request is being gatewayed by setting sesson[:casfiltergateway] to true" controller.session[:casfiltergateway] = true end end if is_valid logger.info "This request is successfully CAS authenticated for user #{controller.session[@@session_username]}!" return true else controller.session[:service] = service_url(controller) logger.info "This request is NOT CAS authenticated, so we will redirect to the login page at: #{redirect_url(controller)}" controller.send :redirect_to, redirect_url(controller) and return false end end alias :filter :filter_r # Requests a proxy ticket from the CAS server and returns it as a ProxyTicketRequest object. # # Note that the ProxyTicketRequest object is returned regardless of whether the request # is successful. You should check the returned object's proxy_ticket field to find out # whether the request resulted in a valid proxy ticket. def request_proxy_ticket(target_service, pgt) r = ProxyTicketRequest.new r.proxy_url = @@proxy_url r.target_service = target_service r.pgt = pgt # FIXME: Why is this here? The only way it would get raised is if the supplied pgt was nil/false? This might be a vestige... raise CAS::ProxyGrantingNotAvailable, "Cannot request a proxy ticket for service #{r.target_service} because no proxy granting ticket (PGT) has been set." unless r.pgt logger.info("Requesting proxy ticket for service: #{r.target_service} with PGT #{pgt}") r.request if r.proxy_ticket logger.info("Got proxy ticket #{r.proxy_ticket} for service #{r.target_service}") else logger.warn("Did not receive a proxy ticket for service #{r.target_service}! Reason: #{r.error_code}: #{r.error_message}") end return r end end private # Retrieves a proxy granting ticket corresponding to the given receipt's # proxy granting ticket IOU from the proxy callback server. # # Returns a CAS::ProxyGrantingTicket object. def self.retrieve_pgt(receipt) retrieve_url = "#{@@proxy_retrieval_url}?pgtIou=#{receipt.pgt_iou}" logger.debug("Will attempt to retrieve the PGT from: #{retrieve_url}") pgt = CAS::ServiceTicketValidator.retrieve(retrieve_url) logger.info("Retrieved the PGT: #{pgt}") return pgt end # Returns true if the given CAS::Receipt is valid; false wotherwise. def self.validate_receipt(receipt) logger.info "Checking that the receipt is valid and coherent..." if not receipt logger.info "No receipt given, so the receipt is invalid" return false elsif @@renew && !receipt.primary_authentication? logger.info "The filter is configured to force primary authentication (i.e. the renew options is set to true), but the receipt was not generated by primary authentication so we consider it invalid" return false end if receipt.proxied? logger.info "The receipt is proxied by proxying service: #{receipt.proxying_service}" if @@authorized_proxies and !@@authorized_proxies.empty? logger.debug "Authorized proxies are: #{@@authorized_proxies.inspect}" if !@@authorized_proxies.include? receipt.proxying_service logger.warn "Receipt was proxied by #{receipt_proxying_service} but this proxying service is not in the list of authorized proxies. The receipt is therefore invalid." return false else logger.info "Receipt is proxied by a valid proxying service." end else logger.info "No authorized proxies set, so any proxy will be considered valid" end else logger.info "Receipt is not proxied" end return true end # Fetches a CAS::Receipt for the given service or proxy ticket # and returns it. # # Takes the current controller as the second argument in order to # guess the current service URL when it is not explicitly set for # the filter. def self.get_receipt_for_ticket(ticket, controller) logger.info "Getting receipt for ticket '#{ticket}'" pv = ProxyTicketValidator.new pv.validate_url = @@validate_url pv.service_ticket = ticket pv.service = controller.session[:service] || service_url(controller) pv.renew = @@renew pv.proxy_callback_url = @@proxy_callback_url receipt = nil logger.debug "ProxyTicketValidator is: #{pv.inspect}" begin receipt = Receipt.new(pv) rescue AuthenticationException => e logger.warn("Getting a receipt for the ProxyTicketValidator threw an exception: #{e}") rescue MalformedServerResponseException => e logger.error("CAS Server returned malformed response:\n\n#{e}") raise e end logger.debug "Receipt is: #{receipt.inspect}" receipt end # Returns the service URL for the current service. # # This will return the @@service_url if it has been explicitly # set; otherwise it will try to guess the service URL based # on the given controller parameters (see guess_service()). def self.service_url(controller) unclean = @@service_url || guess_service(controller) clean = remove_ticket_from_service_uri(unclean) logger.debug("Service URI without ticket is: #{clean}") clean end def self.server_name=(s) puts puts "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" puts "!!! CAS CONFIGURATION WARNING !!!" puts "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" puts puts "CAS::Filter.server_name= no longer does anything." puts puts "If you want to explicitly set the service, try using:" puts "CAS::Filter.service_url = 'http://myservice.com'" puts end # Returns the URL to the login page of the CAS server with # additional parameters like 'renew', and 'gateway' tacked # on as appropriate. The url parameter can be used # to use something other than the login url as the base. # # An optional service parameter can be provided to # override the 'service' part of the URL. # # FIXME: this method is really poorly named :( def self.redirect_url(controller,url=@@login_url,service=nil) if service service = remove_ticket_from_service_uri(service) end service = CGI.escape(service || service_url(controller)) "#{url}?service=#{service}" + ((@@renew)? "&renew=true":"") + ((gateway)? "&gateway=true":"") + ((@@query_string.blank?)? "" : "&" + (@@query_string.collect { |k,v| "#{k}=#{v}"}.join("&"))) end # Tries to figure out the current service URL. # # This is used when the @@service_url has not been explicitly set. # The guessed URL (generally the current URL stripped of some # CAS-specific parameters) is fed to the CAS server so that the # server knows where to redirect back after authentication. # # Also see redirect_url. def self.guess_service(controller) logger.info "Guessing service based on params: #{controller.params.inspect}" # we're assuming that controller.params[:service] is url-encoded! if controller.params and controller.params.include? :service service = controller.params[:service] logger.info "We have a :service param, so we will URI-decode it and use this as the service: #{controller.params[:service]}" return service end req = controller.request if controller.params parms = controller.params.dup else parms = {} end parms.delete("ticket") service = controller.url_for(parms) logger.info "Guessed service is: #{service}" return service end # URI-encodes the def self.escape_service_uri(uri) # FIXME: Why aren't we just using CGi.escape? URI.encode(uri, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]", false, 'U').freeze) end # The service URI should never have a ticket parameter, but we use this to remove # any parameters named "ticket" just in case, as having a "ticket" parameter in the # service URI will generally cause an infinite redirection loop. def self.remove_ticket_from_service_uri(uri) uri.gsub(/ticket=[^&$]*&?/, '') end end # The GatewayFilter is identical to the normal Filter, but has the gateway # option set to true by default. This makes it easier to use in cases where # authentication is optional. # # For example, say your 'index' view is accessible by authenticated and # unauthenticated users, but you want some additional content shown for # authenticated users. You can use the GatewayFilter to check if the user is # already authenticated with CAS and provide them with a service ticket for # the new service. If they are not already authenticated, then they will be # allowed to see the 'index' view without being asked for a login. # # To achieve this in a Rails controller, you should set up your filters as follows: # # before_filter CAS::Filter, :except => [:index] # before_filter CAS::GatewayFilter, :only => [:index] # # Note that you cannot use the 'renew' option with the GatewayFilter since the # 'gateway' and 'renew' options have roughly opposite meanings -- 'renew' forces # re-authentication, while 'gateway' makes authentication optional. class GatewayFilter < Filter self.gateway = true self.renew = false def logout_url uri = URI.parse(super) if uri.query? uri.to_s + "&gateway=true" else uri.to_s + "?gateway=true" end end end class ProxyGrantingNotAvailable < Exception end end class ActionController::AbstractRequest def username session[CAS::Filter.session_username] end end