derfred-rubycas-client 2.0.999

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,256 @@
1
+ module CASClient
2
+ # The client brokers all HTTP transactions with the CAS server.
3
+ class Client
4
+ attr_reader :cas_base_url
5
+ attr_reader :log, :username_session_key, :extra_attributes_session_key
6
+ attr_writer :login_url, :validate_url, :proxy_url, :logout_url, :service_url
7
+ attr_accessor :proxy_callback_url, :proxy_retrieval_url
8
+
9
+ def initialize(conf = nil)
10
+ configure(conf) if conf
11
+ end
12
+
13
+ def configure(conf)
14
+ raise ArgumentError, "Missing :cas_base_url parameter!" unless conf[:cas_base_url]
15
+
16
+ @cas_base_url = conf[:cas_base_url].gsub(/\/$/, '')
17
+
18
+ @login_url = conf[:login_url]
19
+ @logout_url = conf[:logout_url]
20
+ @validate_url = conf[:validate_url]
21
+ @proxy_url = conf[:proxy_url]
22
+ @service_url = conf[:service_url]
23
+ @proxy_callback_url = conf[:proxy_callback_url]
24
+ @proxy_retrieval_url = conf[:proxy_retrieval_url]
25
+
26
+ @username_session_key = conf[:username_session_key] || :cas_user
27
+ @extra_attributes_session_key = conf[:extra_attributes_session_key] || :cas_extra_attributes
28
+
29
+ @log = CASClient::LoggerWrapper.new
30
+ @log.set_real_logger(conf[:logger]) if conf[:logger]
31
+ end
32
+
33
+ def login_url
34
+ @login_url || (cas_base_url + "/login")
35
+ end
36
+
37
+ def validate_url
38
+ @validate_url || (cas_base_url + "/proxyValidate")
39
+ end
40
+
41
+ # Returns the CAS server's logout url.
42
+ #
43
+ # If a logout_url has not been explicitly configured,
44
+ # the default is cas_base_url + "/logout".
45
+ #
46
+ # destination_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 the above behaviour describes RubyCAS-Server
50
+ # -- other CAS server implementations might use this
51
+ # parameter differently (or not at all).
52
+ # follow_url:: This satisfies section 2.3.1 of the CAS protocol spec.
53
+ # See http://www.ja-sig.org/products/cas/overview/protocol
54
+ def logout_url(destination_url = nil, follow_url = nil)
55
+ url = @logout_url || (cas_base_url + "/logout")
56
+
57
+ if destination_url
58
+ # if present, remove the 'ticket' parameter from the destination_url
59
+ duri = URI.parse(destination_url)
60
+ h = duri.query ? query_to_hash(duri.query) : {}
61
+ h.delete('ticket')
62
+ duri.query = hash_to_query(h)
63
+ destination_url = duri.to_s.gsub(/\?$/, '')
64
+ end
65
+
66
+ if destination_url || follow_url
67
+ uri = URI.parse(url)
68
+ h = uri.query ? query_to_hash(uri.query) : {}
69
+ h['destination'] = destination_url if destination_url
70
+ h['url'] = follow_url if follow_url
71
+ uri.query = hash_to_query(h)
72
+ uri.to_s
73
+ else
74
+ url
75
+ end
76
+ end
77
+
78
+ def proxy_url
79
+ @proxy_url || (cas_base_url + "/proxy")
80
+ end
81
+
82
+ def validate_service_ticket(st)
83
+ uri = URI.parse(validate_url)
84
+ h = uri.query ? query_to_hash(uri.query) : {}
85
+ h['service'] = st.service
86
+ h['ticket'] = st.ticket
87
+ h['renew'] = 1 if st.renew
88
+ h['pgtUrl'] = proxy_callback_url if proxy_callback_url
89
+ uri.query = hash_to_query(h)
90
+
91
+ st.response = request_cas_response(uri, ValidationResponse)
92
+
93
+ return st
94
+ end
95
+ alias validate_proxy_ticket validate_service_ticket
96
+
97
+ # Returns true if the configured CAS server is up and responding;
98
+ # false otherwise.
99
+ def cas_server_is_up?
100
+ uri = URI.parse(login_url)
101
+
102
+ log.debug "Checking if CAS server at URI '#{uri}' is up..."
103
+
104
+ https = Net::HTTP.new(uri.host, uri.port)
105
+ https.use_ssl = (uri.scheme == 'https')
106
+
107
+ begin
108
+ raw_res = https.start do |conn|
109
+ conn.get("#{uri.path}?#{uri.query}")
110
+ end
111
+ rescue Errno::ECONNREFUSED => e
112
+ log.warn "CAS server did not respond! (#{e.inspect})"
113
+ return false
114
+ end
115
+
116
+ log.debug "CAS server responded with #{raw_res.inspect}:\n#{raw_res.body}"
117
+
118
+ return raw_res.kind_of?(Net::HTTPSuccess)
119
+ end
120
+
121
+ # Requests a login using the given credentials for the given service;
122
+ # returns a LoginResponse object.
123
+ def login_to_service(credentials, service)
124
+ lt = request_login_ticket
125
+
126
+ data = credentials.merge(
127
+ :lt => lt,
128
+ :service => service
129
+ )
130
+
131
+ res = submit_data_to_cas(login_url, data)
132
+ CASClient::LoginResponse.new(res)
133
+ end
134
+
135
+ # Requests a login ticket from the CAS server for use in a login request;
136
+ # returns a LoginTicket object.
137
+ #
138
+ # This only works with RubyCAS-Server, since obtaining login
139
+ # tickets in this manner is not part of the official CAS spec.
140
+ def request_login_ticket
141
+ uri = URI.parse(login_url+'Ticket')
142
+ https = Net::HTTP.new(uri.host, uri.port)
143
+ https.use_ssl = (uri.scheme == 'https')
144
+ res = https.post(uri.path, ';')
145
+
146
+ raise CASException, res.body unless res.kind_of? Net::HTTPSuccess
147
+
148
+ res.body.strip
149
+ end
150
+
151
+ # Requests a proxy ticket from the CAS server for the given service
152
+ # using the given pgt (proxy granting ticket); returns a ProxyTicket
153
+ # object.
154
+ #
155
+ # The pgt required to request a proxy ticket is obtained as part of
156
+ # a ValidationResponse.
157
+ def request_proxy_ticket(pgt, target_service)
158
+ uri = URI.parse(proxy_url)
159
+ h = uri.query ? query_to_hash(uri.query) : {}
160
+ h['pgt'] = pgt.ticket
161
+ h['targetService'] = target_service
162
+ uri.query = hash_to_query(h)
163
+
164
+ pr = request_cas_response(uri, ProxyResponse)
165
+
166
+ pt = ProxyTicket.new(pr.proxy_ticket, target_service)
167
+ pt.response = pr
168
+
169
+ return pt
170
+ end
171
+
172
+ def retrieve_proxy_granting_ticket(pgt_iou)
173
+ uri = URI.parse(proxy_retrieval_url)
174
+ uri.query = (uri.query ? uri.query + "&" : "") + "pgtIou=#{CGI.escape(pgt_iou)}"
175
+ retrieve_url = uri.to_s
176
+
177
+ log.debug "Retrieving PGT for PGT IOU #{pgt_iou.inspect} from #{retrieve_url.inspect}"
178
+
179
+ # https = Net::HTTP.new(uri.host, uri.port)
180
+ # https.use_ssl = (uri.scheme == 'https')
181
+ # res = https.post(uri.path, ';')
182
+ uri = URI.parse(uri) unless uri.kind_of? URI
183
+ https = Net::HTTP.new(uri.host, uri.port)
184
+ https.use_ssl = (uri.scheme == 'https')
185
+ res = https.start do |conn|
186
+ conn.get("#{uri.path}?#{uri.query}")
187
+ end
188
+
189
+
190
+ raise CASException, res.body unless res.kind_of? Net::HTTPSuccess
191
+
192
+ ProxyGrantingTicket.new(res.body.strip, pgt_iou)
193
+ end
194
+
195
+ def add_service_to_login_url(service_url)
196
+ uri = URI.parse(login_url)
197
+ uri.query = (uri.query ? uri.query + "&" : "") + "service=#{CGI.escape(service_url)}"
198
+ uri.to_s
199
+ end
200
+
201
+ private
202
+ # Fetches a CAS response of the given type from the given URI.
203
+ # Type should be either ValidationResponse or ProxyResponse.
204
+ def request_cas_response(uri, type)
205
+ log.debug "Requesting CAS response for URI #{uri}"
206
+
207
+ uri = URI.parse(uri) unless uri.kind_of? URI
208
+ https = Net::HTTP.new(uri.host, uri.port)
209
+ https.use_ssl = (uri.scheme == 'https')
210
+
211
+ begin
212
+ raw_res = https.start do |conn|
213
+ conn.get("#{uri.path}?#{uri.query}")
214
+ end
215
+ rescue Errno::ECONNREFUSED => e
216
+ log.error "CAS server did not respond! (#{e.inspect})"
217
+ raise "The CAS authentication server at #{uri} is not responding!"
218
+ end
219
+
220
+ # We accept responses of type 422 since RubyCAS-Server generates these
221
+ # in response to requests from the client that are processable but contain
222
+ # invalid CAS data (for example an invalid service ticket).
223
+ if raw_res.kind_of?(Net::HTTPSuccess) || raw_res.code.to_i == 422
224
+ log.debug "CAS server responded with #{raw_res.inspect}:\n#{raw_res.body}"
225
+ else
226
+ log.error "CAS server responded with an error! (#{raw_res.inspect})"
227
+ raise "The CAS authentication server at #{uri} responded with an error (#{raw_res.inspect})!"
228
+ end
229
+
230
+ type.new(raw_res.body)
231
+ end
232
+
233
+ # Submits some data to the given URI and returns a Net::HTTPResponse.
234
+ def submit_data_to_cas(uri, data)
235
+ uri = URI.parse(uri) unless uri.kind_of? URI
236
+ req = Net::HTTP::Post.new(uri.path)
237
+ req.set_form_data(data, ';')
238
+ https = Net::HTTP.new(uri.host, uri.port)
239
+ https.use_ssl = (uri.scheme == 'https')
240
+ https.start {|conn| conn.request(req) }
241
+ end
242
+
243
+ def query_to_hash(query)
244
+ CGI.parse(query)
245
+ end
246
+
247
+ def hash_to_query(hash)
248
+ pairs = []
249
+ hash.each do |k, vals|
250
+ vals = [vals] unless vals.kind_of? Array
251
+ vals.each {|v| pairs << "#{CGI.escape(k)}=#{CGI.escape(v)}"}
252
+ end
253
+ pairs.join("&")
254
+ end
255
+ end
256
+ 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
+
@@ -0,0 +1,76 @@
1
+ require 'pstore'
2
+
3
+ # Rails controller that responds to proxy generating ticket callbacks from the CAS server and allows
4
+ # for retrieval of those PGTs.
5
+ class CasProxyCallbackController < ActionController::Base
6
+
7
+ # Receives a proxy granting ticket from the CAS server and stores it in the database.
8
+ # Note that this action should ALWAYS be called via https, otherwise you have a gaping security hole.
9
+ # In fact, the JA-SIG implementation of the CAS server will refuse to send PGTs to non-https URLs.
10
+ def receive_pgt
11
+ #FIXME: these checks don't work because REMOTE_HOST doesn't work consistently under all web servers (for example it doesn't work at all under mongrel)
12
+ # ... need to find a reliable way to check if the request came through from a reverse HTTPS proxy -- until then I'm disabling this check
13
+ #render_error "PGTs can be received only via HTTPS or local connections." and return unless
14
+ # request.ssl? or request.env['REMOTE_HOST'] == "127.0.0.1"
15
+
16
+ pgtIou = params['pgtIou']
17
+
18
+ # CAS Protocol spec says that the argument should be called 'pgt', but the JA-SIG CAS server seems to use pgtId.
19
+ # To accomodate this, we check for both parameters, although 'pgt' takes precedence over 'pgtId'.
20
+ pgtId = params['pgt'] || params['pgtId']
21
+
22
+ # We need to render a response with HTTP status code 200 when no pgtIou/pgtId is specified because CAS seems first
23
+ # call the action without any parameters (maybe to check if the server responds correctly)
24
+ render :text => "Okay, the server is up, but please specify a pgtIou and pgtId." and return unless pgtIou and pgtId
25
+
26
+ # TODO: pstore contents should probably be encrypted...
27
+ pstore = open_pstore
28
+
29
+ pstore.transaction do
30
+ pstore[pgtIou] = pgtId
31
+ end
32
+
33
+ render :text => "PGT received. Thank you!" and return
34
+ end
35
+
36
+ # Retreives a proxy granting ticket, sends it to output, and deletes the pgt from session storage.
37
+ # Note that this action should ALWAYS be called via https, otherwise you have a gaping security hole --
38
+ # in fact, the action will not work if the request is not made via SSL or is not local (we allow for local
39
+ # non-SSL requests since this allows for the use of reverse HTTPS proxies like Pound).
40
+ def retrieve_pgt
41
+ #render_error "You can only retrieve PGTs via HTTPS or local connections." and return unless
42
+ # request.ssl? or request.env['REMOTE_HOST'] == "127.0.0.1"
43
+
44
+ pgtIou = params['pgtIou']
45
+
46
+ render_error "No pgtIou specified. Cannot retreive the pgtId." and return unless pgtIou
47
+
48
+ pstore = open_pstore
49
+
50
+ pgt = nil
51
+ pstore.transaction do
52
+ pgt = pstore[pgtIou]
53
+ end
54
+
55
+ if not pgt
56
+ render_error "Invalid pgtIou specified. Perhaps this pgt has already been retrieved?" and return
57
+ end
58
+
59
+ render :text => pgt
60
+
61
+ # TODO: need to periodically clean the storage, otherwise it will just keep growing
62
+ pstore.transaction do
63
+ pstore.delete pgtIou
64
+ end
65
+ end
66
+
67
+ private
68
+ def render_error(msg)
69
+ # Note that the error messages are mostly just for debugging, since the CAS server never reads them.
70
+ render :text => msg, :status => 500
71
+ end
72
+
73
+ def open_pstore
74
+ PStore.new("#{RAILS_ROOT}/tmp/cas_pgt.pstore")
75
+ end
76
+ end
@@ -0,0 +1,111 @@
1
+ module CASClient
2
+ module Frameworks
3
+ module Rails
4
+ class Filter
5
+
6
+ cattr_accessor :config, :log, :client
7
+
8
+ # These are initialized when you call configure.
9
+ @@config = nil
10
+ @@client = nil
11
+ @@log = nil
12
+
13
+ def self.use_gatewaying?
14
+ @@config[:use_gatewaying]
15
+ end
16
+
17
+ def self.filter(controller)
18
+ raise "Cannot use the CASClient filter because it has not yet been configured." if config.nil?
19
+
20
+ case RequestHandler.determine_response(condition, use_gatewaying?)
21
+ when :single_sign_out
22
+ controller.send(:render, :text => "CAS Single-Sign-Out request intercepted.")
23
+ return false
24
+ when :allow
25
+ return true
26
+ when :to_login
27
+ redirect_to_cas_for_authentication(controller)
28
+ return false
29
+ when :validation_failed
30
+ redirect_to_cas_for_authentication(controller)
31
+ return false
32
+ end
33
+ end
34
+
35
+ def self.configure(config)
36
+ @@config = config
37
+ @@config[:logger] = RAILS_DEFAULT_LOGGER unless @@config[:logger]
38
+ @@client = CASClient::Client.new(config)
39
+ @@log = client.log
40
+ end
41
+
42
+ # Returns the login URL for the current controller.
43
+ # Useful when you want to provide a "Login" link in a GatewayFilter'ed
44
+ # action.
45
+ def self.login_url(controller)
46
+ service_url = read_service_url(controller)
47
+ url = client.add_service_to_login_url(service_url)
48
+ log.debug("Generated login url: #{url}")
49
+ return url
50
+ end
51
+
52
+ # Clears the given controller's local Rails session, does some local
53
+ # CAS cleanup, and redirects to the CAS logout page. Additionally, the
54
+ # <tt>request.referer</tt> value from the <tt>controller</tt> instance
55
+ # is passed to the CAS server as a 'destination' parameter. This
56
+ # allows RubyCAS server to provide a follow-up login page allowing
57
+ # the user to log back in to the service they just logged out from
58
+ # using a different username and password. Other CAS server
59
+ # implemenations may use this 'destination' parameter in different
60
+ # ways.
61
+ # If given, the optional <tt>service</tt> URL overrides
62
+ # <tt>request.referer</tt>.
63
+ def self.logout(controller, service = nil)
64
+ referer = service || controller.request.referer
65
+ st = controller.session[:cas_last_valid_ticket]
66
+ delete_service_session_lookup(st) if st
67
+ controller.send(:reset_session)
68
+ controller.send(:redirect_to, client.logout_url(referer))
69
+ end
70
+
71
+ def self.redirect_to_cas_for_authentication(controller)
72
+ redirect_url = login_url(controller)
73
+
74
+ if use_gatewaying?
75
+ controller.session[:cas_sent_to_gateway] = true
76
+ redirect_url << "&gateway=true"
77
+ else
78
+ controller.session[:cas_sent_to_gateway] = false
79
+ end
80
+
81
+ if controller.session[:previous_redirect_to_cas] &&
82
+ controller.session[:previous_redirect_to_cas] > (Time.now - 1.second)
83
+ 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!")
84
+ controller.session[:cas_validation_retry_count] ||= 0
85
+
86
+ if controller.session[:cas_validation_retry_count] > 3
87
+ log.error("Redirection loop intercepted. Client at #{controller.request.remote_ip.inspect} will be redirected back to login page and forced to renew authentication.")
88
+ redirect_url += "&renew=1&redirection_loop_intercepted=1"
89
+ end
90
+
91
+ controller.session[:cas_validation_retry_count] += 1
92
+ else
93
+ controller.session[:cas_validation_retry_count] = 0
94
+ end
95
+ controller.session[:previous_redirect_to_cas] = Time.now
96
+
97
+ log.debug("Redirecting to #{redirect_url.inspect}")
98
+ controller.send(:redirect_to, redirect_url)
99
+ end
100
+
101
+ end
102
+
103
+
104
+ class GatewayFilter < Filter
105
+ def self.use_gatewaying?
106
+ return true unless @@config[:use_gatewaying] == false
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end