rubycas-client 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ # run very flat apps with merb -I <app file>.
2
+
3
+ # Uncomment for DataMapper ORM
4
+ # use_orm :datamapper
5
+
6
+ # Uncomment for ActiveRecord ORM
7
+ # use_orm :activerecord
8
+
9
+ # Uncomment for Sequel ORM
10
+ # use_orm :sequel
11
+
12
+ $:.unshift(File.dirname(__FILE__) / ".." / ".." / "lib")
13
+ require "casclient"
14
+ require 'casclient/frameworks/merb/filter'
15
+ #
16
+ # ==== Pick what you test with
17
+ #
18
+
19
+ # This defines which test framework the generators will use.
20
+ # RSpec is turned on by default.
21
+ #
22
+ # To use Test::Unit, you need to install the merb_test_unit gem.
23
+ # To use RSpec, you don't have to install any additional gems, since
24
+ # merb-core provides support for RSpec.
25
+ #
26
+ # use_test :test_unit
27
+ use_test :rspec
28
+
29
+ #
30
+ # ==== Choose which template engine to use by default
31
+ #
32
+
33
+ # Merb can generate views for different template engines, choose your favourite as the default.
34
+
35
+ use_template_engine :erb
36
+ # use_template_engine :haml
37
+
38
+ Merb::Config.use { |c|
39
+ c[:framework] = { :public => [Merb.root / "public", nil] }
40
+ c[:session_store] = 'cookie'
41
+ c[:exception_details] = true
42
+ c[:log_level] = :debug # or error, warn, info or fatal
43
+ c[:log_stream] = STDOUT
44
+ c[:session_secret_key] = '9f30c015f2132d217bfb81e31668a74fadbdf672'
45
+ c[:log_file] = Merb.root / "log" / "merb.log"
46
+
47
+ c[:reload_classes] = true
48
+ c[:reload_templates] = true
49
+ }
50
+
51
+
52
+ Merb::Plugins.config[:"rubycas-client"] = {
53
+ :cas_base_url => "http://localhost:7777"
54
+ }
55
+
56
+ Merb::Router.prepare do
57
+ match('/').to(:controller => 'merb_auth_cas', :action =>'index').name(:default)
58
+ end
59
+
60
+ class MerbAuthCas < Merb::Controller
61
+ include CASClient::Frameworks::Merb::Filter
62
+ before :cas_filter
63
+
64
+ def index
65
+ "Hi, #{session[:cas_user]}"
66
+ end
67
+ end
@@ -0,0 +1,24 @@
1
+ require "rubygems"
2
+
3
+ # Add the local gems dir if found within the app root; any dependencies loaded
4
+ # hereafter will try to load from the local gems before loading system gems.
5
+ if (local_gem_dir = File.join(File.dirname(__FILE__), '..', 'gems')) && $BUNDLE.nil?
6
+ $BUNDLE = true; Gem.clear_paths; Gem.path.unshift(local_gem_dir)
7
+ end
8
+
9
+ require "spec"
10
+ require "merb-core"
11
+
12
+ Merb::Config.use do |c|
13
+ c[:session_store] = "memory"
14
+ end
15
+
16
+ Merb.start_environment(:testing => true,
17
+ :adapter => 'runner',
18
+ :environment => ENV['MERB_ENV'] || 'test')
19
+
20
+ Spec::Runner.configure do |config|
21
+ config.include(Merb::Test::ViewHelper)
22
+ config.include(Merb::Test::RouteHelper)
23
+ config.include(Merb::Test::ControllerHelper)
24
+ end
@@ -11,6 +11,8 @@ module CASClient
11
11
  end
12
12
 
13
13
  def configure(conf)
14
+ #TODO: raise error if conf contains unrecognized cas options (this would help detect user typos in the config)
15
+
14
16
  raise ArgumentError, "Missing :cas_base_url parameter!" unless conf[:cas_base_url]
15
17
 
16
18
  @cas_base_url = conf[:cas_base_url].gsub(/\/$/, '')
@@ -43,20 +45,31 @@ module CASClient
43
45
  # If a logout_url has not been explicitly configured,
44
46
  # the default is cas_base_url + "/logout".
45
47
  #
46
- # service_url:: Set this if you want the user to be
47
- # able to immediately log back in. Generally
48
- # you'll want to use something like <tt>request.referer</tt>.
49
- # Note that this only works with RubyCAS-Server.
50
- # back_url:: This satisfies section 2.3.1 of the CAS protocol spec.
51
- # See http://www.ja-sig.org/products/cas/overview/protocol
52
- def logout_url(service_url = nil, back_url = nil)
48
+ # destination_url:: Set this if you want the user to be
49
+ # able to immediately log back in. Generally
50
+ # you'll want to use something like <tt>request.referer</tt>.
51
+ # Note that the above behaviour describes RubyCAS-Server
52
+ # -- other CAS server implementations might use this
53
+ # parameter differently (or not at all).
54
+ # follow_url:: This satisfies section 2.3.1 of the CAS protocol spec.
55
+ # See http://www.ja-sig.org/products/cas/overview/protocol
56
+ def logout_url(destination_url = nil, follow_url = nil)
53
57
  url = @logout_url || (cas_base_url + "/logout")
54
58
 
55
- if service_url || back_url
59
+ if destination_url
60
+ # if present, remove the 'ticket' parameter from the destination_url
61
+ duri = URI.parse(destination_url)
62
+ h = duri.query ? query_to_hash(duri.query) : {}
63
+ h.delete('ticket')
64
+ duri.query = hash_to_query(h)
65
+ destination_url = duri.to_s.gsub(/\?$/, '')
66
+ end
67
+
68
+ if destination_url || follow_url
56
69
  uri = URI.parse(url)
57
70
  h = uri.query ? query_to_hash(uri.query) : {}
58
- h['service'] = service_url if service_url
59
- h['url'] = back_url if back_url
71
+ h['destination'] = destination_url if destination_url
72
+ h['url'] = follow_url if follow_url
60
73
  uri.query = hash_to_query(h)
61
74
  uri.to_s
62
75
  else
@@ -83,6 +96,30 @@ module CASClient
83
96
  end
84
97
  alias validate_proxy_ticket validate_service_ticket
85
98
 
99
+ # Returns true if the configured CAS server is up and responding;
100
+ # false otherwise.
101
+ def cas_server_is_up?
102
+ uri = URI.parse(login_url)
103
+
104
+ log.debug "Checking if CAS server at URI '#{uri}' is up..."
105
+
106
+ https = Net::HTTP.new(uri.host, uri.port)
107
+ https.use_ssl = (uri.scheme == 'https')
108
+
109
+ begin
110
+ raw_res = https.start do |conn|
111
+ conn.get("#{uri.path}?#{uri.query}")
112
+ end
113
+ rescue Errno::ECONNREFUSED => e
114
+ log.warn "CAS server did not respond! (#{e.inspect})"
115
+ return false
116
+ end
117
+
118
+ log.debug "CAS server responded with #{raw_res.inspect}:\n#{raw_res.body}"
119
+
120
+ return raw_res.kind_of?(Net::HTTPSuccess)
121
+ end
122
+
86
123
  # Requests a login using the given credentials for the given service;
87
124
  # returns a LoginResponse object.
88
125
  def login_to_service(credentials, service)
@@ -167,18 +204,30 @@ module CASClient
167
204
  # Fetches a CAS response of the given type from the given URI.
168
205
  # Type should be either ValidationResponse or ProxyResponse.
169
206
  def request_cas_response(uri, type)
170
- log.debug "Requesting CAS response form URI #{uri.inspect}"
207
+ log.debug "Requesting CAS response for URI #{uri}"
171
208
 
172
209
  uri = URI.parse(uri) unless uri.kind_of? URI
173
210
  https = Net::HTTP.new(uri.host, uri.port)
174
211
  https.use_ssl = (uri.scheme == 'https')
175
- raw_res = https.start do |conn|
176
- conn.get("#{uri.path}?#{uri.query}")
177
- end
178
212
 
179
- #TODO: check to make sure that response code is 200 and handle errors otherwise
213
+ begin
214
+ raw_res = https.start do |conn|
215
+ conn.get("#{uri.path}?#{uri.query}")
216
+ end
217
+ rescue Errno::ECONNREFUSED => e
218
+ log.error "CAS server did not respond! (#{e.inspect})"
219
+ raise "The CAS authentication server at #{uri} is not responding!"
220
+ end
180
221
 
181
- log.debug "CAS Responded with #{raw_res.inspect}:\n#{raw_res.body}"
222
+ # We accept responses of type 422 since RubyCAS-Server generates these
223
+ # in response to requests from the client that are processable but contain
224
+ # invalid CAS data (for example an invalid service ticket).
225
+ if raw_res.kind_of?(Net::HTTPSuccess) || raw_res.code.to_i == 422
226
+ log.debug "CAS server responded with #{raw_res.inspect}:\n#{raw_res.body}"
227
+ else
228
+ log.error "CAS server responded with an error! (#{raw_res.inspect})"
229
+ raise "The CAS authentication server at #{uri} responded with an error (#{raw_res.inspect})!"
230
+ end
182
231
 
183
232
  type.new(raw_res.body)
184
233
  end
@@ -0,0 +1,105 @@
1
+ module CASClient
2
+ module Frameworks
3
+ module Merb
4
+ module Filter
5
+ attr_reader :client
6
+
7
+ def cas_filter
8
+ @client ||= CASClient::Client.new(config)
9
+
10
+ service_ticket = read_ticket(self)
11
+
12
+ cas_login_url = client.add_service_to_login_url(read_service_url(self))
13
+
14
+ last_service_ticket = session[:cas_last_valid_ticket]
15
+ if (service_ticket && last_service_ticket &&
16
+ last_service_ticket.ticket == service_ticket.ticket &&
17
+ last_service_ticket.service == service_ticket.service)
18
+
19
+ # warn() rather than info() because we really shouldn't be re-validating the same ticket.
20
+ # The only time when this is acceptable is if the user manually does a refresh and the ticket
21
+ # happens to be in the URL.
22
+ log.warn("Reusing previously validated ticket since the new ticket and service are the same.")
23
+ service_ticket = last_service_ticket
24
+ elsif last_service_ticket &&
25
+ !config[:authenticate_on_every_request] &&
26
+ session[client.username_session_key]
27
+ # Re-use the previous ticket if the user already has a local CAS session (i.e. if they were already
28
+ # previously authenticated for this service). This is to prevent redirection to the CAS server on every
29
+ # request.
30
+ # This behaviour can be disabled (so that every request is routed through the CAS server) by setting
31
+ # the :authenticate_on_every_request config option to false.
32
+ log.debug "Existing local CAS session detected for #{session[client.username_session_key].inspect}. "+
33
+ "Previous ticket #{last_service_ticket.ticket.inspect} will be re-used."
34
+ service_ticket = last_service_ticket
35
+ end
36
+
37
+ if service_ticket
38
+ client.validate_service_ticket(service_ticket) unless service_ticket.has_been_validated?
39
+ validation_response = service_ticket.response
40
+
41
+ if service_ticket.is_valid?
42
+ log.info("Ticket #{service_ticket.inspect} for service #{service_ticket.service.inspect} " +
43
+ "belonging to user #{validation_response.user.inspect} is VALID.")
44
+
45
+ session[client.username_session_key] = validation_response.user
46
+ session[client.extra_attributes_session_key] = validation_response.extra_attributes
47
+
48
+ # Store the ticket in the session to avoid re-validating the same service
49
+ # ticket with the CAS server.
50
+ session[:cas_last_valid_ticket] = service_ticket
51
+ return true
52
+ else
53
+ log.warn("Ticket #{service_ticket.ticket.inspect} failed validation -- " +
54
+ "#{validation_response.failure_code}: #{validation_response.failure_message}")
55
+ redirect cas_login_url
56
+ throw :halt
57
+ end
58
+ else
59
+ log.warn("No ticket -- redirecting to #{cas_login_url}")
60
+ redirect cas_login_url
61
+ throw :halt
62
+ end
63
+ end
64
+
65
+ private
66
+ # Copied from Rails adapter
67
+ def read_ticket(controller)
68
+ ticket = controller.params[:ticket]
69
+
70
+ return nil unless ticket
71
+
72
+ log.debug("Request contains ticket #{ticket.inspect}.")
73
+
74
+ if ticket =~ /^PT-/
75
+ ProxyTicket.new(ticket, read_service_url(controller), controller.params[:renew])
76
+ else
77
+ ServiceTicket.new(ticket, read_service_url(controller), controller.params[:renew])
78
+ end
79
+ end
80
+
81
+ # Also copied from Rails adapter
82
+ def read_service_url(controller)
83
+ if config[:service_url]
84
+ log.debug("Using explicitly set service url: #{config[:service_url]}")
85
+ return config[:service_url]
86
+ end
87
+
88
+ params = controller.params.dup
89
+ params.delete(:ticket)
90
+ service_url = request.protocol + '://' + request.host / controller.url(params.to_hash.symbolize_keys!)
91
+ log.debug("Guessed service url: #{service_url.inspect}")
92
+ return service_url
93
+ end
94
+
95
+ def log
96
+ ::Merb.logger
97
+ end
98
+
99
+ def config
100
+ ::Merb::Plugins.config[:"rubycas-client"]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,110 @@
1
+ # The 'cas' strategy attempts to login users based on the CAS protocol
2
+ # http://www.ja-sig.org/products/cas/overview/background/index.html
3
+ #
4
+ # install the rubycas-client gem
5
+ # http://rubyforge.org/projects/rubycas-client/
6
+ #
7
+ require 'casclient'
8
+ class Merb::Authentication
9
+ module Strategies
10
+ class CAS < Merb::Authentication::Strategy
11
+
12
+ include CASClient
13
+
14
+ def run!
15
+ @client ||= Client.new(config)
16
+
17
+ service_ticket = read_ticket
18
+
19
+ cas_login_url = @client.add_service_to_login_url(service_url)
20
+
21
+ last_service_ticket = session[:cas_last_valid_ticket]
22
+ if (service_ticket && last_service_ticket &&
23
+ last_service_ticket.ticket == service_ticket.ticket &&
24
+ last_service_ticket.service == service_ticket.service)
25
+
26
+ # warn() rather than info() because we really shouldn't be re-validating the same ticket.
27
+ # The only time when this is acceptable is if the user manually does a refresh and the ticket
28
+ # happens to be in the URL.
29
+ log.warn("Reusing previously validated ticket since the new ticket and service are the same.")
30
+ service_ticket = last_service_ticket
31
+ elsif last_service_ticket &&
32
+ !config[:authenticate_on_every_request] &&
33
+ session[@client.username_session_key]
34
+ # Re-use the previous ticket if the user already has a local CAS session (i.e. if they were already
35
+ # previously authenticated for this service). This is to prevent redirection to the CAS server on every
36
+ # request.
37
+ # This behaviour can be disabled (so that every request is routed through the CAS server) by setting
38
+ # the :authenticate_on_every_request config option to false.
39
+ log.debug "Existing local CAS session detected for #{session[@client.username_session_key].inspect}. "+
40
+ "Previous ticket #{last_service_ticket.ticket.inspect} will be re-used."
41
+ service_ticket = last_service_ticket
42
+ end
43
+
44
+ if service_ticket
45
+ @client.validate_service_ticket(service_ticket) unless service_ticket.has_been_validated?
46
+ validation_response = service_ticket.response
47
+
48
+ if service_ticket.is_valid?
49
+ log.info("Ticket #{service_ticket.inspect} for service #{service_ticket.service.inspect} " +
50
+ "belonging to user #{validation_response.user.inspect} is VALID.")
51
+
52
+ session[@client.username_session_key] = validation_response.user
53
+ session[@client.extra_attributes_session_key] = validation_response.extra_attributes
54
+
55
+ # Store the ticket in the session to avoid re-validating the same service
56
+ # ticket with the CAS server.
57
+ session[:cas_last_valid_ticket] = service_ticket
58
+ return true
59
+ else
60
+ log.warn("Ticket #{service_ticket.ticket.inspect} failed validation -- " +
61
+ "#{validation_response.failure_code}: #{validation_response.failure_message}")
62
+ redirect!(cas_login_url)
63
+ return false
64
+ end
65
+ else
66
+ log.warn("No ticket -- redirecting to #{cas_login_url}")
67
+ redirect!(cas_login_url)
68
+ return false
69
+ end
70
+ end
71
+
72
+ def read_ticket
73
+ ticket = request.params[:ticket]
74
+
75
+ return nil unless ticket
76
+
77
+ log.debug("Request contains ticket #{ticket.inspect}.")
78
+
79
+ if ticket =~ /^PT-/
80
+ ProxyTicket.new(ticket, service_url, request.params[:renew])
81
+ else
82
+ ServiceTicket.new(ticket, service_url, request.params[:renew])
83
+ end
84
+ end
85
+
86
+ def service_url
87
+ if config[:service_url]
88
+ log.debug("Using explicitly set service url: #{config[:service_url]}")
89
+ return config[:service_url]
90
+ end
91
+
92
+ params = request.params.dup
93
+ params.delete(:ticket)
94
+ service_url = "#{request.protocol}://#{request.host}" + request.path
95
+ log.debug("Guessed service url: #{service_url.inspect}")
96
+ return service_url
97
+ end
98
+
99
+ def config
100
+ ::Merb::Plugins.config[:"rubycas-client"]
101
+ end
102
+
103
+ def log
104
+ ::Merb.logger
105
+ end
106
+
107
+ end # CAS
108
+ end # Strategies
109
+ end
110
+
@@ -12,17 +12,39 @@ module CASClient
12
12
  class << self
13
13
  def filter(controller)
14
14
  raise "Cannot use the CASClient filter because it has not yet been configured." if config.nil?
15
+
16
+ last_st = controller.session[:cas_last_valid_ticket]
17
+
18
+ if single_sign_out(controller)
19
+ controller.send(:render, :text => "CAS Single-Sign-Out request intercepted.")
20
+ return false
21
+ end
15
22
 
16
23
  st = read_ticket(controller)
17
24
 
18
- lst = controller.session[:cas_last_valid_ticket]
25
+ is_new_session = true
19
26
 
20
- if st && lst && lst.ticket == st.ticket && lst.service == st.service
27
+ if st && last_st &&
28
+ last_st.ticket == st.ticket &&
29
+ last_st.service == st.service
21
30
  # warn() rather than info() because we really shouldn't be re-validating the same ticket.
22
- # The only time when this is acceptable is if the user manually does a refresh and the ticket
23
- # happens to be in the URL.
24
- log.warn("Re-using previously validated ticket since the new ticket and service are the same.")
25
- st = lst
31
+ # The only situation where this is acceptable is if the user manually does a refresh and
32
+ # the same ticket happens to be in the URL.
33
+ log.warn("Re-using previously validated ticket since the ticket id and service are the same.")
34
+ st = last_st
35
+ is_new_session = false
36
+ elsif last_st &&
37
+ !config[:authenticate_on_every_request] &&
38
+ controller.session[client.username_session_key]
39
+ # Re-use the previous ticket if the user already has a local CAS session (i.e. if they were already
40
+ # previously authenticated for this service). This is to prevent redirection to the CAS server on every
41
+ # request.
42
+ # This behaviour can be disabled (so that every request is routed through the CAS server) by setting
43
+ # the :authenticate_on_every_request config option to false.
44
+ log.debug "Existing local CAS session detected for #{controller.session[client.username_session_key].inspect}. "+
45
+ "Previous ticket #{last_st.ticket.inspect} will be re-used."
46
+ st = last_st
47
+ is_new_session = false
26
48
  end
27
49
 
28
50
  if st
@@ -30,30 +52,47 @@ module CASClient
30
52
  vr = st.response
31
53
 
32
54
  if st.is_valid?
33
- log.info("Ticket #{st.ticket.inspect} for service #{st.service.inspect} belonging to user #{vr.user.inspect} is VALID.")
34
- controller.session[client.username_session_key] = vr.user
35
- controller.session[client.extra_attributes_session_key] = vr.extra_attributes
36
-
37
- # RubyCAS-Client 1.x used :casfilteruser as it's username session key,
38
- # so we need to set this here to ensure compatibility with configurations
39
- # built around the old client.
40
- controller.session[:casfilteruser] = vr.user
41
-
55
+ if is_new_session
56
+ log.info("Ticket #{st.ticket.inspect} for service #{st.service.inspect} belonging to user #{vr.user.inspect} is VALID.")
57
+ controller.session[client.username_session_key] = vr.user.dup
58
+ controller.session[client.extra_attributes_session_key] = HashWithIndifferentAccess.new(vr.extra_attributes.dup)
59
+
60
+ if vr.extra_attributes
61
+ log.debug("Extra user attributes provided along with ticket #{st.ticket.inspect}: #{vr.extra_attributes.inspect}.")
62
+ end
63
+
64
+ # RubyCAS-Client 1.x used :casfilteruser as it's username session key,
65
+ # so we need to set this here to ensure compatibility with configurations
66
+ # built around the old client.
67
+ controller.session[:casfilteruser] = vr.user
68
+
69
+ if config[:enable_single_sign_out]
70
+ f = store_service_session_lookup(st, controller.request.session_options[:id] || controller.session.session_id)
71
+ log.debug("Wrote service session lookup file to #{f.inspect} with session id #{controller.request.session_options[:id] || controller.session.session_id.inspect}.")
72
+ end
73
+ end
74
+
42
75
  # Store the ticket in the session to avoid re-validating the same service
43
76
  # ticket with the CAS server.
44
77
  controller.session[:cas_last_valid_ticket] = st
45
78
 
46
79
  if vr.pgt_iou
47
- log.info("Receipt has a proxy-granting ticket IOU. Attempting to retrieve the proxy-granting ticket...")
48
- pgt = client.retrieve_proxy_granting_ticket(vr.pgt_iou)
49
- if pgt
50
- log.debug("Got PGT #{pgt.ticket.inspect} for PGT IOU #{pgt.iou.inspect}. This will be stored in the session.")
51
- controller.session[:cas_pgt] = pgt
52
- # For backwards compatibility with RubyCAS-Client 1.x configurations...
53
- controller.session[:casfilterpgt] = pgt
80
+ unless controller.session[:cas_pgt] && controller.session[:cas_pgt].ticket && controller.session[:cas_pgt].iou == vr.pgt_iou
81
+ log.info("Receipt has a proxy-granting ticket IOU. Attempting to retrieve the proxy-granting ticket...")
82
+ pgt = client.retrieve_proxy_granting_ticket(vr.pgt_iou)
83
+
84
+ if pgt
85
+ log.debug("Got PGT #{pgt.ticket.inspect} for PGT IOU #{pgt.iou.inspect}. This will be stored in the session.")
86
+ controller.session[:cas_pgt] = pgt
87
+ # For backwards compatibility with RubyCAS-Client 1.x configurations...
88
+ controller.session[:casfilterpgt] = pgt
89
+ else
90
+ log.error("Failed to retrieve a PGT for PGT IOU #{vr.pgt_iou}!")
91
+ end
54
92
  else
55
- log.error("Failed to retrieve a PGT for PGT IOU #{vr.pgt_iou}!")
93
+ log.info("PGT is present in session and PGT IOU #{vr.pgt_iou} matches the saved PGT IOU. Not retrieving new PGT.")
56
94
  end
95
+
57
96
  end
58
97
 
59
98
  return true
@@ -62,16 +101,13 @@ module CASClient
62
101
  redirect_to_cas_for_authentication(controller)
63
102
  return false
64
103
  end
65
- elsif !config[:authenticate_on_every_request] && controller.session[client.username_session_key]
66
- # Don't re-authenticate with the CAS server if we already previously authenticated and the
67
- # :authenticate_on_every_request option is disabled (it's disabled by default).
68
- log.debug "Existing local CAS session detected for #{controller.session[client.username_session_key].inspect}. "+
69
- "User will not be re-authenticated."
70
- return true
71
104
  else
72
105
  if returning_from_gateway?(controller)
73
106
  log.info "Returning from CAS gateway without authentication."
74
-
107
+
108
+ # reset, so that we can retry authentication if there is a subsequent request
109
+ controller.session[:cas_sent_to_gateway] = false
110
+
75
111
  if use_gatewaying?
76
112
  log.info "This CAS client is configured to use gatewaying, so we will permit the user to continue without authentication."
77
113
  return true
@@ -96,13 +132,37 @@ module CASClient
96
132
  @@config[:use_gatewaying]
97
133
  end
98
134
 
99
- def returning_from_gateway?(controller)
100
- controller.session[:cas_sent_to_gateway]
135
+ # Returns the login URL for the current controller.
136
+ # Useful when you want to provide a "Login" link in a GatewayFilter'ed
137
+ # action.
138
+ def login_url(controller)
139
+ service_url = read_service_url(controller)
140
+ url = client.add_service_to_login_url(service_url)
141
+ log.debug("Generated login url: #{url}")
142
+ return url
143
+ end
144
+
145
+ # Clears the given controller's local Rails session, does some local
146
+ # CAS cleanup, and redirects to the CAS logout page. Additionally, the
147
+ # <tt>request.referer</tt> value from the <tt>controller</tt> instance
148
+ # is passed to the CAS server as a 'destination' parameter. This
149
+ # allows RubyCAS server to provide a follow-up login page allowing
150
+ # the user to log back in to the service they just logged out from
151
+ # using a different username and password. Other CAS server
152
+ # implemenations may use this 'destination' parameter in different
153
+ # ways.
154
+ # If given, the optional <tt>service</tt> URL overrides
155
+ # <tt>request.referer</tt>.
156
+ def logout(controller, service = nil)
157
+ referer = service || controller.request.referer
158
+ st = controller.session[:cas_last_valid_ticket]
159
+ delete_service_session_lookup(st) if st
160
+ controller.send(:reset_session)
161
+ controller.send(:redirect_to, client.logout_url(referer))
101
162
  end
102
163
 
103
164
  def redirect_to_cas_for_authentication(controller)
104
- service_url = read_service_url(controller)
105
- redirect_url = client.add_service_to_login_url(service_url)
165
+ redirect_url = login_url(controller)
106
166
 
107
167
  if use_gatewaying?
108
168
  controller.session[:cas_sent_to_gateway] = true
@@ -111,11 +171,92 @@ module CASClient
111
171
  controller.session[:cas_sent_to_gateway] = false
112
172
  end
113
173
 
174
+ if controller.session[:previous_redirect_to_cas] &&
175
+ controller.session[:previous_redirect_to_cas] > (Time.now - 1.second)
176
+ 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!")
177
+ controller.session[:cas_validation_retry_count] ||= 0
178
+
179
+ if controller.session[:cas_validation_retry_count] > 3
180
+ log.error("Redirection loop intercepted. Client at #{controller.request.remote_ip.inspect} will be redirected back to login page and forced to renew authentication.")
181
+ redirect_url += "&renew=1&redirection_loop_intercepted=1"
182
+ end
183
+
184
+ controller.session[:cas_validation_retry_count] += 1
185
+ else
186
+ controller.session[:cas_validation_retry_count] = 0
187
+ end
188
+ controller.session[:previous_redirect_to_cas] = Time.now
189
+
114
190
  log.debug("Redirecting to #{redirect_url.inspect}")
115
191
  controller.send(:redirect_to, redirect_url)
116
192
  end
117
193
 
118
194
  private
195
+ def single_sign_out(controller)
196
+
197
+ # Avoid calling raw_post (which may consume the post body) if
198
+ # this seems to be a file upload
199
+ if content_type = controller.request.headers["CONTENT_TYPE"] &&
200
+ content_type =~ %r{^multipart/}
201
+ return false
202
+ end
203
+
204
+ if controller.request.post? &&
205
+ controller.params['logoutRequest'] &&
206
+ controller.params['logoutRequest'] =~
207
+ %r{^<samlp:LogoutRequest.*?<samlp:SessionIndex>(.*)</samlp:SessionIndex>}m
208
+ # TODO: Maybe check that the request came from the registered CAS server? Although this might be
209
+ # pointless since it's easily spoofable...
210
+ si = $~[1]
211
+
212
+ unless config[:enable_single_sign_out]
213
+ 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)."
214
+ return false
215
+ end
216
+
217
+ log.debug "Intercepted single-sign-out request for CAS session #{si.inspect}."
218
+
219
+ begin
220
+ required_sess_store = ActiveRecord::SessionStore
221
+ current_sess_store = ActionController::Base.session_store
222
+ rescue NameError
223
+ # for older versions of Rails (prior to 2.3)
224
+ required_sess_store = CGI::Session::ActiveRecordStore
225
+ current_sess_store = ActionController::Base.session_options[:database_manager]
226
+ end
227
+
228
+
229
+ if current_sess_store == required_sess_store
230
+ session_id = read_service_session_lookup(si)
231
+
232
+ if session_id
233
+ session = current_sess_store::Session.find_by_session_id(session_id)
234
+ if session
235
+ session.destroy
236
+ log.debug("Destroyed #{session.inspect} for session #{session_id.inspect} corresponding to service ticket #{si.inspect}.")
237
+ else
238
+ log.debug("Data for session #{session_id.inspect} was not found. It may have already been cleared by a local CAS logout request.")
239
+ end
240
+
241
+ log.info("Single-sign-out for session #{session_id.inspect} completed successfuly.")
242
+ else
243
+ log.warn("Couldn't destroy session with SessionIndex #{si} because no corresponding session id could be looked up.")
244
+ end
245
+ else
246
+ log.error "Cannot process logout request because this Rails application's session store is "+
247
+ " #{current_sess_store.name.inspect}. Single Sign-Out only works with the "+
248
+ " #{required_sess_store.name.inspect} session store."
249
+ end
250
+
251
+ # Return true to indicate that a single-sign-out request was detected
252
+ # and that further processing of the request is unnecessary.
253
+ return true
254
+ end
255
+
256
+ # This is not a single-sign-out request.
257
+ return false
258
+ end
259
+
119
260
  def read_ticket(controller)
120
261
  ticket = controller.params[:ticket]
121
262
 
@@ -130,6 +271,10 @@ module CASClient
130
271
  end
131
272
  end
132
273
 
274
+ def returning_from_gateway?(controller)
275
+ controller.session[:cas_sent_to_gateway]
276
+ end
277
+
133
278
  def read_service_url(controller)
134
279
  if config[:service_url]
135
280
  log.debug("Using explicitly set service url: #{config[:service_url]}")
@@ -142,6 +287,46 @@ module CASClient
142
287
  log.debug("Guessed service url: #{service_url.inspect}")
143
288
  return service_url
144
289
  end
290
+
291
+ # Creates a file in tmp/sessions linking a SessionTicket
292
+ # with the local Rails session id. The file is named
293
+ # cas_sess.<session ticket> and its text contents is the corresponding
294
+ # Rails session id.
295
+ # Returns the filename of the lookup file created.
296
+ def store_service_session_lookup(st, sid)
297
+ st = st.ticket if st.kind_of? ServiceTicket
298
+ f = File.new(filename_of_service_session_lookup(st), 'w')
299
+ f.write(sid)
300
+ f.close
301
+ return f.path
302
+ end
303
+
304
+ # Returns the local Rails session ID corresponding to the given
305
+ # ServiceTicket. This is done by reading the contents of the
306
+ # cas_sess.<session ticket> file created in a prior call to
307
+ # #store_service_session_lookup.
308
+ def read_service_session_lookup(st)
309
+ st = st.ticket if st.kind_of? ServiceTicket
310
+ ssl_filename = filename_of_service_session_lookup(st)
311
+ return File.exists?(ssl_filename) && IO.read(ssl_filename)
312
+ end
313
+
314
+ # Removes a stored relationship between a ServiceTicket and a local
315
+ # Rails session id. This should be called when the session is being
316
+ # closed.
317
+ #
318
+ # See #store_service_session_lookup.
319
+ def delete_service_session_lookup(st)
320
+ st = st.ticket if st.kind_of? ServiceTicket
321
+ ssl_filename = filename_of_service_session_lookup(st)
322
+ File.delete(ssl_filename) if File.exists?(ssl_filename)
323
+ end
324
+
325
+ # Returns the path and filename of the service session lookup file.
326
+ def filename_of_service_session_lookup(st)
327
+ st = st.ticket if st.kind_of? ServiceTicket
328
+ return "#{RAILS_ROOT}/tmp/sessions/cas_sess.#{st}"
329
+ end
145
330
  end
146
331
  end
147
332
 
@@ -152,4 +337,4 @@ module CASClient
152
337
  end
153
338
  end
154
339
  end
155
- end
340
+ end