echocas-client 2.1.1

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