echocas-client 2.1.1

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,372 @@
1
+ module CASClient
2
+ module Frameworks
3
+ module Rails
4
+ class Filter
5
+ cattr_reader :config, :log, :client
6
+
7
+ # These are initialized when you call configure.
8
+ @@config = nil
9
+ @@client = nil
10
+ @@log = nil
11
+ @@fake_user = nil
12
+
13
+
14
+ class << self
15
+ def filter(controller)
16
+ raise "Cannot use the CASClient filter because it has not yet been configured." if config.nil?
17
+
18
+
19
+ if @@fake_user
20
+ controller.session[client.username_session_key] = @@fake_user
21
+ controller.session[:casfilteruser] = @@fake_user
22
+ return true
23
+ end
24
+
25
+
26
+ last_st = controller.session[:cas_last_valid_ticket]
27
+
28
+ if single_sign_out(controller)
29
+ controller.send(:render, :text => "CAS Single-Sign-Out request intercepted.")
30
+ return false
31
+ end
32
+
33
+ st = read_ticket(controller)
34
+
35
+ is_new_session = true
36
+
37
+ if st && last_st &&
38
+ last_st.ticket == st.ticket &&
39
+ last_st.service == st.service
40
+ # warn() rather than info() because we really shouldn't be re-validating the same ticket.
41
+ # The only situation where this is acceptable is if the user manually does a refresh and
42
+ # the same ticket happens to be in the URL.
43
+ log.warn("Re-using previously validated ticket since the ticket id and service are the same.")
44
+ st = last_st
45
+ is_new_session = false
46
+ elsif last_st &&
47
+ !config[:authenticate_on_every_request] &&
48
+ controller.session[client.username_session_key]
49
+ # Re-use the previous ticket if the user already has a local CAS session (i.e. if they were already
50
+ # previously authenticated for this service). This is to prevent redirection to the CAS server on every
51
+ # request.
52
+ #
53
+ # This behaviour can be disabled (so that every request is routed through the CAS server) by setting
54
+ # the :authenticate_on_every_request config option to true. However, this is not desirable since
55
+ # it will almost certainly break POST request, AJAX calls, etc.
56
+ log.debug "Existing local CAS session detected for #{controller.session[client.username_session_key].inspect}. "+
57
+ "Previous ticket #{last_st.ticket.inspect} will be re-used."
58
+ st = last_st
59
+ is_new_session = false
60
+ end
61
+
62
+ if st
63
+ client.validate_service_ticket(st) unless st.has_been_validated?
64
+ vr = st.response
65
+
66
+ if st.is_valid?
67
+ if is_new_session
68
+ log.info("Ticket #{st.ticket.inspect} for service #{st.service.inspect} belonging to user #{vr.user.inspect} is VALID.")
69
+ controller.session[client.username_session_key] = vr.user.dup
70
+ controller.session[client.extra_attributes_session_key] = HashWithIndifferentAccess.new(vr.extra_attributes)
71
+
72
+ if vr.extra_attributes
73
+ log.debug("Extra user attributes provided along with ticket #{st.ticket.inspect}: #{vr.extra_attributes.inspect}.")
74
+ end
75
+
76
+ # RubyCAS-Client 1.x used :casfilteruser as it's username session key,
77
+ # so we need to set this here to ensure compatibility with configurations
78
+ # built around the old client.
79
+ controller.session[:casfilteruser] = vr.user
80
+
81
+ if config[:enable_single_sign_out]
82
+ f = store_service_session_lookup(st, controller.request.session_options[:id] || controller.session.session_id)
83
+ log.debug("Wrote service session lookup file to #{f.inspect} with session id #{controller.request.session_options[:id] || controller.session.session_id.inspect}.")
84
+ end
85
+ end
86
+
87
+ # Store the ticket in the session to avoid re-validating the same service
88
+ # ticket with the CAS server.
89
+ controller.session[:cas_last_valid_ticket] = st
90
+
91
+ if vr.pgt_iou
92
+ unless controller.session[:cas_pgt] && controller.session[:cas_pgt].ticket && controller.session[:cas_pgt].iou == vr.pgt_iou
93
+ log.info("Receipt has a proxy-granting ticket IOU. Attempting to retrieve the proxy-granting ticket...")
94
+ pgt = client.retrieve_proxy_granting_ticket(vr.pgt_iou)
95
+
96
+ if pgt
97
+ log.debug("Got PGT #{pgt.ticket.inspect} for PGT IOU #{pgt.iou.inspect}. This will be stored in the session.")
98
+ controller.session[:cas_pgt] = pgt
99
+ # For backwards compatibility with RubyCAS-Client 1.x configurations...
100
+ controller.session[:casfilterpgt] = pgt
101
+ else
102
+ log.error("Failed to retrieve a PGT for PGT IOU #{vr.pgt_iou}!")
103
+ end
104
+ else
105
+ log.info("PGT is present in session and PGT IOU #{vr.pgt_iou} matches the saved PGT IOU. Not retrieving new PGT.")
106
+ end
107
+
108
+ end
109
+
110
+ return true
111
+ else
112
+ log.warn("Ticket #{st.ticket.inspect} failed validation -- #{vr.failure_code}: #{vr.failure_message}")
113
+ unauthorized!(controller, vr)
114
+ return false
115
+ end
116
+ else # no service ticket was present in the request
117
+ if returning_from_gateway?(controller)
118
+ log.info "Returning from CAS gateway without authentication."
119
+
120
+ # unset, to allow for the next request to be authenticated if necessary
121
+ controller.session[:cas_sent_to_gateway] = false
122
+
123
+ if use_gatewaying?
124
+ log.info "This CAS client is configured to use gatewaying, so we will permit the user to continue without authentication."
125
+ return true
126
+ else
127
+ log.warn "The CAS client is NOT configured to allow gatewaying, yet this request was gatewayed. Something is not right!"
128
+ end
129
+ end
130
+
131
+ unauthorized!(controller)
132
+ return false
133
+ end
134
+ end
135
+
136
+ def configure(config)
137
+ @@config = config
138
+ @@config[:logger] = RAILS_DEFAULT_LOGGER unless @@config[:logger]
139
+ @@client = CASClient::Client.new(config)
140
+ @@log = client.log
141
+ end
142
+
143
+ # used to allow faking for testing
144
+ # with cucumber and other tools.
145
+ # use like
146
+ # CASClient::Frameworks::Rails::Filter.fake("homer")
147
+ def fake(username)
148
+ @@fake_user = username
149
+ end
150
+
151
+ def use_gatewaying?
152
+ @@config[:use_gatewaying]
153
+ end
154
+
155
+ # Returns the login URL for the current controller.
156
+ # Useful when you want to provide a "Login" link in a GatewayFilter'ed
157
+ # action.
158
+ def login_url(controller)
159
+ service_url = read_service_url(controller)
160
+ url = client.add_service_to_login_url(service_url)
161
+ log.debug("Generated login url: #{url}")
162
+ return url
163
+ end
164
+
165
+ # Clears the given controller's local Rails session, does some local
166
+ # CAS cleanup, and redirects to the CAS logout page. Additionally, the
167
+ # <tt>request.referer</tt> value from the <tt>controller</tt> instance
168
+ # is passed to the CAS server as a 'destination' parameter. This
169
+ # allows RubyCAS server to provide a follow-up login page allowing
170
+ # the user to log back in to the service they just logged out from
171
+ # using a different username and password. Other CAS server
172
+ # implemenations may use this 'destination' parameter in different
173
+ # ways.
174
+ # If given, the optional <tt>service</tt> URL overrides
175
+ # <tt>request.referer</tt>.
176
+ def logout(controller, service = nil)
177
+ referer = service || controller.request.referer
178
+ st = controller.session[:cas_last_valid_ticket]
179
+ delete_service_session_lookup(st) if st
180
+ controller.send(:reset_session)
181
+ controller.send(:redirect_to, client.logout_url(referer))
182
+ end
183
+
184
+ def unauthorized!(controller, vr = nil)
185
+ if controller.params[:format] == "xml"
186
+ if vr
187
+ controller.send(:render, :xml => "<errors><error>#{vr.failure_message}</error></errors>", :status => 401)
188
+ else
189
+ controller.send(:head, 401)
190
+ end
191
+ else
192
+ redirect_to_cas_for_authentication(controller)
193
+ end
194
+ end
195
+
196
+ def redirect_to_cas_for_authentication(controller)
197
+ redirect_url = login_url(controller)
198
+
199
+ if use_gatewaying?
200
+ controller.session[:cas_sent_to_gateway] = true
201
+ redirect_url << "&gateway=true"
202
+ else
203
+ controller.session[:cas_sent_to_gateway] = false
204
+ end
205
+
206
+ if controller.session[:previous_redirect_to_cas] &&
207
+ controller.session[:previous_redirect_to_cas] > (Time.now - 1.second)
208
+ 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!")
209
+ controller.session[:cas_validation_retry_count] ||= 0
210
+
211
+ if controller.session[:cas_validation_retry_count] > 3
212
+ log.error("Redirection loop intercepted. Client at #{controller.request.remote_ip.inspect} will be redirected back to login page and forced to renew authentication.")
213
+ redirect_url += "&renew=1&redirection_loop_intercepted=1"
214
+ end
215
+
216
+ controller.session[:cas_validation_retry_count] += 1
217
+ else
218
+ controller.session[:cas_validation_retry_count] = 0
219
+ end
220
+ controller.session[:previous_redirect_to_cas] = Time.now
221
+
222
+ log.debug("Redirecting to #{redirect_url.inspect}")
223
+ controller.send(:redirect_to, redirect_url)
224
+ end
225
+
226
+ private
227
+ def single_sign_out(controller)
228
+
229
+ # Avoid calling raw_post (which may consume the post body) if
230
+ # this seems to be a file upload
231
+ if content_type = controller.request.headers["CONTENT_TYPE"] &&
232
+ content_type =~ %r{^multipart/}
233
+ return false
234
+ end
235
+
236
+ if controller.request.post? &&
237
+ controller.params['logoutRequest'] &&
238
+ controller.params['logoutRequest'] =~
239
+ %r{^<samlp:LogoutRequest.*?<samlp:SessionIndex>(.*)</samlp:SessionIndex>}m
240
+ # TODO: Maybe check that the request came from the registered CAS server? Although this might be
241
+ # pointless since it's easily spoofable...
242
+ si = $~[1]
243
+
244
+ unless config[:enable_single_sign_out]
245
+ 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)."
246
+ return false
247
+ end
248
+
249
+ log.debug "Intercepted single-sign-out request for CAS session #{si.inspect}."
250
+
251
+ begin
252
+ required_sess_store = ActiveRecord::SessionStore
253
+ current_sess_store = ActionController::Base.session_store
254
+ rescue NameError
255
+ # for older versions of Rails (prior to 2.3)
256
+ required_sess_store = CGI::Session::ActiveRecordStore
257
+ current_sess_store = ActionController::Base.session_options[:database_manager]
258
+ end
259
+
260
+
261
+ if current_sess_store == required_sess_store
262
+ session_id = read_service_session_lookup(si)
263
+
264
+ if session_id
265
+ session = current_sess_store::Session.find_by_session_id(session_id)
266
+ if session
267
+ session.destroy
268
+ log.debug("Destroyed #{session.inspect} for session #{session_id.inspect} corresponding to service ticket #{si.inspect}.")
269
+ else
270
+ log.debug("Data for session #{session_id.inspect} was not found. It may have already been cleared by a local CAS logout request.")
271
+ end
272
+
273
+ log.info("Single-sign-out for session #{session_id.inspect} completed successfuly.")
274
+ else
275
+ log.warn("Couldn't destroy session with SessionIndex #{si} because no corresponding session id could be looked up.")
276
+ end
277
+ else
278
+ log.error "Cannot process logout request because this Rails application's session store is "+
279
+ " #{current_sess_store.name.inspect}. Single Sign-Out only works with the "+
280
+ " #{required_sess_store.name.inspect} session store."
281
+ end
282
+
283
+ # Return true to indicate that a single-sign-out request was detected
284
+ # and that further processing of the request is unnecessary.
285
+ return true
286
+ end
287
+
288
+ # This is not a single-sign-out request.
289
+ return false
290
+ end
291
+
292
+ def read_ticket(controller)
293
+ ticket = controller.params[:ticket]
294
+
295
+ return nil unless ticket
296
+
297
+ log.debug("Request contains ticket #{ticket.inspect}.")
298
+
299
+ if ticket =~ /^PT-/
300
+ ProxyTicket.new(ticket, read_service_url(controller), controller.params[:renew])
301
+ else
302
+ ServiceTicket.new(ticket, read_service_url(controller), controller.params[:renew])
303
+ end
304
+ end
305
+
306
+ def returning_from_gateway?(controller)
307
+ controller.session[:cas_sent_to_gateway]
308
+ end
309
+
310
+ def read_service_url(controller)
311
+ if config[:service_url]
312
+ log.debug("Using explicitly set service url: #{config[:service_url]}")
313
+ return config[:service_url]
314
+ end
315
+
316
+ params = controller.params.dup
317
+ params.delete(:ticket)
318
+ service_url = controller.url_for(params)
319
+ log.debug("Guessed service url: #{service_url.inspect}")
320
+ return service_url
321
+ end
322
+
323
+ # Creates a file in tmp/sessions linking a SessionTicket
324
+ # with the local Rails session id. The file is named
325
+ # cas_sess.<session ticket> and its text contents is the corresponding
326
+ # Rails session id.
327
+ # Returns the filename of the lookup file created.
328
+ def store_service_session_lookup(st, sid)
329
+ st = st.ticket if st.kind_of? ServiceTicket
330
+ f = File.new(filename_of_service_session_lookup(st), 'w')
331
+ f.write(sid)
332
+ f.close
333
+ return f.path
334
+ end
335
+
336
+ # Returns the local Rails session ID corresponding to the given
337
+ # ServiceTicket. This is done by reading the contents of the
338
+ # cas_sess.<session ticket> file created in a prior call to
339
+ # #store_service_session_lookup.
340
+ def read_service_session_lookup(st)
341
+ st = st.ticket if st.kind_of? ServiceTicket
342
+ ssl_filename = filename_of_service_session_lookup(st)
343
+ return File.exists?(ssl_filename) && IO.read(ssl_filename)
344
+ end
345
+
346
+ # Removes a stored relationship between a ServiceTicket and a local
347
+ # Rails session id. This should be called when the session is being
348
+ # closed.
349
+ #
350
+ # See #store_service_session_lookup.
351
+ def delete_service_session_lookup(st)
352
+ st = st.ticket if st.kind_of? ServiceTicket
353
+ ssl_filename = filename_of_service_session_lookup(st)
354
+ File.delete(ssl_filename) if File.exists?(ssl_filename)
355
+ end
356
+
357
+ # Returns the path and filename of the service session lookup file.
358
+ def filename_of_service_session_lookup(st)
359
+ st = st.ticket if st.kind_of? ServiceTicket
360
+ return "#{RAILS_ROOT}/tmp/sessions/cas_sess.#{st}"
361
+ end
362
+ end
363
+ end
364
+
365
+ class GatewayFilter < Filter
366
+ def self.use_gatewaying?
367
+ return true unless @@config[:use_gatewaying] == false
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,191 @@
1
+ module CASClient
2
+ module XmlResponse
3
+ attr_reader :xml, :parse_datetime
4
+ attr_reader :failure_code, :failure_message
5
+
6
+ def check_and_parse_xml(raw_xml)
7
+ begin
8
+ doc = REXML::Document.new(raw_xml)
9
+ rescue REXML::ParseException => e
10
+ raise BadResponseException,
11
+ "MALFORMED CAS RESPONSE:\n#{raw_xml.inspect}\n\nEXCEPTION:\n#{e}"
12
+ end
13
+
14
+ unless doc.elements && doc.elements["cas:serviceResponse"]
15
+ raise BadResponseException,
16
+ "This does not appear to be a valid CAS response (missing cas:serviceResponse root element)!\nXML DOC:\n#{doc.to_s}"
17
+ end
18
+
19
+ return doc.elements["cas:serviceResponse"].elements[1]
20
+ end
21
+
22
+ def to_s
23
+ xml.to_s
24
+ end
25
+ end
26
+
27
+ # Represents a response from the CAS server to a 'validate' request
28
+ # (i.e. after validating a service/proxy ticket).
29
+ class ValidationResponse
30
+ include XmlResponse
31
+
32
+ attr_reader :protocol, :user, :pgt_iou, :proxies, :extra_attributes
33
+
34
+ def initialize(raw_text)
35
+ parse(raw_text)
36
+ end
37
+
38
+ def parse(raw_text)
39
+ raise BadResponseException,
40
+ "CAS response is empty/blank." if raw_text.blank?
41
+ @parse_datetime = Time.now
42
+
43
+ if raw_text =~ /^(yes|no)\n(.*?)\n$/m
44
+ @protocol = 1.0
45
+ @valid = $~[1] == 'yes'
46
+ @user = $~[2]
47
+ return
48
+ end
49
+
50
+ @xml = check_and_parse_xml(raw_text)
51
+
52
+ # if we got this far then we've got a valid XML response, so we're doing CAS 2.0
53
+ @protocol = 2.0
54
+
55
+ if is_success?
56
+ @user = @xml.elements["cas:user"].text.strip if @xml.elements["cas:user"]
57
+ @pgt_iou = @xml.elements["cas:proxyGrantingTicket"].text.strip if @xml.elements["cas:proxyGrantingTicket"]
58
+
59
+ proxy_els = @xml.elements.to_a('//cas:authenticationSuccess/cas:proxies/cas:proxy')
60
+ if proxy_els.size > 0
61
+ @proxies = []
62
+ proxy_els.each do |el|
63
+ @proxies << el.text
64
+ end
65
+ end
66
+
67
+ @extra_attributes = {}
68
+ @xml.elements.to_a('//cas:authenticationSuccess/*').each do |el|
69
+ @extra_attributes.merge!(Hash.from_xml(el.to_s)) unless el.prefix == 'cas'
70
+ end
71
+
72
+ # unserialize extra attributes
73
+ @extra_attributes.each do |k, v|
74
+ if v.blank?
75
+ @extra_attributes[k] = nil
76
+ else
77
+ @extra_attributes[k] = YAML.load(v)
78
+ end
79
+ end
80
+ elsif is_failure?
81
+ @failure_code = @xml.elements['//cas:authenticationFailure'].attributes['code']
82
+ @failure_message = @xml.elements['//cas:authenticationFailure'].text.strip
83
+ else
84
+ # this should never happen, since the response should already have been recognized as invalid
85
+ raise BadResponseException, "BAD CAS RESPONSE:\n#{raw_text.inspect}\n\nXML DOC:\n#{doc.inspect}"
86
+ end
87
+
88
+ end
89
+
90
+ def is_success?
91
+ @valid == true || (protocol > 1.0 && xml.name == "authenticationSuccess")
92
+ end
93
+
94
+ def is_failure?
95
+ @valid == false || (protocol > 1.0 && xml.name == "authenticationFailure" )
96
+ end
97
+ end
98
+
99
+ # Represents a response from the CAS server to a proxy ticket request
100
+ # (i.e. after requesting a proxy ticket).
101
+ class ProxyResponse
102
+ include XmlResponse
103
+
104
+ attr_reader :proxy_ticket
105
+
106
+ def initialize(raw_text)
107
+ parse(raw_text)
108
+ end
109
+
110
+ def parse(raw_text)
111
+ raise BadResponseException,
112
+ "CAS response is empty/blank." if raw_text.blank?
113
+ @parse_datetime = Time.now
114
+
115
+ @xml = check_and_parse_xml(raw_text)
116
+
117
+ if is_success?
118
+ @proxy_ticket = @xml.elements["cas:proxyTicket"].text.strip if @xml.elements["cas:proxyTicket"]
119
+ elsif is_failure?
120
+ @failure_code = @xml.elements['//cas:proxyFailure'].attributes['code']
121
+ @failure_message = @xml.elements['//cas:proxyFailure'].text.strip
122
+ else
123
+ # this should never happen, since the response should already have been recognized as invalid
124
+ raise BadResponseException, "BAD CAS RESPONSE:\n#{raw_text.inspect}\n\nXML DOC:\n#{doc.inspect}"
125
+ end
126
+
127
+ end
128
+
129
+ def is_success?
130
+ xml.name == "proxySuccess"
131
+ end
132
+
133
+ def is_failure?
134
+ xml.name == "proxyFailure"
135
+ end
136
+ end
137
+
138
+ # Represents a response from the CAS server to a login request
139
+ # (i.e. after submitting a username/password).
140
+ class LoginResponse
141
+ attr_reader :tgt, :ticket, :service_redirect_url
142
+ attr_reader :failure_message
143
+
144
+ def initialize(http_response = nil)
145
+ parse_http_response(http_response) if http_response
146
+ end
147
+
148
+ def parse_http_response(http_response)
149
+ header = http_response.to_hash
150
+
151
+ # FIXME: this regexp might be incorrect...
152
+ if header['set-cookie'] &&
153
+ header['set-cookie'].first &&
154
+ header['set-cookie'].first =~ /tgt=([^&]+);/
155
+ @tgt = $~[1]
156
+ end
157
+
158
+ location = header['location'].first if header['location'] && header['location'].first
159
+ if location =~ /ticket=([^&]+)/
160
+ @ticket = $~[1]
161
+ end
162
+
163
+ if (http_response.kind_of?(Net::HTTPSuccess) || http_response.kind_of?(Net::HTTPFound)) && @ticket.present?
164
+ log.info("Login was successful for ticket: #{@ticket.inspect}.")
165
+ else
166
+ @failure = true
167
+ # Try to extract the error message -- this only works with RubyCAS-Server.
168
+ # For other servers we just return the entire response body (i.e. the whole error page).
169
+ body = http_response.body
170
+ if body =~ /<div class="messagebox mistake">(.*?)<\/div>/m
171
+ @failure_message = $~[1].strip
172
+ else
173
+ @failure_message = body
174
+ end
175
+ end
176
+
177
+ @service_redirect_url = location
178
+ end
179
+
180
+ def is_success?
181
+ !@failure && !ticket.blank?
182
+ end
183
+
184
+ def is_failure?
185
+ @failure == true
186
+ end
187
+ end
188
+
189
+ class BadResponseException < CASException
190
+ end
191
+ end
@@ -0,0 +1,38 @@
1
+ module CASClient
2
+ # Represents a CAS service ticket.
3
+ class ServiceTicket
4
+ attr_reader :ticket, :service, :renew
5
+ attr_accessor :response
6
+
7
+ def initialize(ticket, service, renew = false)
8
+ @ticket = ticket
9
+ @service = service
10
+ @renew = renew
11
+ end
12
+
13
+ def is_valid?
14
+ response.is_success?
15
+ end
16
+
17
+ def has_been_validated?
18
+ not response.nil?
19
+ end
20
+ end
21
+
22
+ # Represents a CAS proxy ticket.
23
+ class ProxyTicket < ServiceTicket
24
+ end
25
+
26
+ class ProxyGrantingTicket
27
+ attr_reader :ticket, :iou
28
+
29
+ def initialize(ticket, iou)
30
+ @ticket = ticket
31
+ @iou = iou
32
+ end
33
+
34
+ def to_s
35
+ ticket
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ module CASClient #:nodoc:
2
+ module VERSION #:nodoc:
3
+ MAJOR = 2
4
+ MINOR = 1
5
+ TINY = 0
6
+
7
+ STRING = [MAJOR, MINOR, TINY].join('.')
8
+ end
9
+ end