rubycas-client 2.0.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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