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,259 @@
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
+ #TODO: raise error if conf contains unrecognized cas options (this would help detect user typos in the config)
15
+
16
+ raise ArgumentError, "Missing :cas_base_url parameter!" unless conf[:cas_base_url]
17
+
18
+ @cas_base_url = conf[:cas_base_url].gsub(/\/$/, '')
19
+
20
+ @login_url = conf[:login_url]
21
+ @logout_url = conf[:logout_url]
22
+ @validate_url = conf[:validate_url]
23
+ @proxy_url = conf[:proxy_url]
24
+ @service_url = conf[:service_url]
25
+ @proxy_callback_url = conf[:proxy_callback_url]
26
+ @proxy_retrieval_url = conf[:proxy_retrieval_url]
27
+
28
+ @username_session_key = conf[:username_session_key] || :cas_user
29
+ @extra_attributes_session_key = conf[:extra_attributes_session_key] || :cas_extra_attributes
30
+
31
+ @log = CASClient::LoggerWrapper.new
32
+ @log.set_real_logger(conf[:logger]) if conf[:logger]
33
+ end
34
+
35
+ def login_url
36
+ @login_url || (cas_base_url + "/login")
37
+ end
38
+
39
+ def validate_url
40
+ @validate_url || (cas_base_url + "/proxyValidate")
41
+ end
42
+
43
+ # Returns the CAS server's logout url.
44
+ #
45
+ # If a logout_url has not been explicitly configured,
46
+ # the default is cas_base_url + "/logout".
47
+ #
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)
57
+ url = @logout_url || (cas_base_url + "/logout")
58
+
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
69
+ uri = URI.parse(url)
70
+ h = uri.query ? query_to_hash(uri.query) : {}
71
+ h['destination'] = destination_url if destination_url
72
+ h['url'] = follow_url if follow_url
73
+ h['gateway'] = 'true'
74
+ uri.query = hash_to_query(h)
75
+ uri.to_s
76
+ else
77
+ url
78
+ end
79
+ end
80
+
81
+ def proxy_url
82
+ @proxy_url || (cas_base_url + "/proxy")
83
+ end
84
+
85
+ def validate_service_ticket(st)
86
+ uri = URI.parse(validate_url)
87
+ h = uri.query ? query_to_hash(uri.query) : {}
88
+ h['service'] = st.service
89
+ h['ticket'] = st.ticket
90
+ h['renew'] = 1 if st.renew
91
+ h['pgtUrl'] = proxy_callback_url if proxy_callback_url
92
+ uri.query = hash_to_query(h)
93
+
94
+ st.response = request_cas_response(uri, ValidationResponse)
95
+
96
+ return st
97
+ end
98
+ alias validate_proxy_ticket validate_service_ticket
99
+
100
+ # Returns true if the configured CAS server is up and responding;
101
+ # false otherwise.
102
+ def cas_server_is_up?
103
+ uri = URI.parse(login_url)
104
+
105
+ log.debug "Checking if CAS server at URI '#{uri}' is up..."
106
+
107
+ https = Net::HTTP.new(uri.host, uri.port)
108
+ https.use_ssl = (uri.scheme == 'https')
109
+
110
+ begin
111
+ raw_res = https.start do |conn|
112
+ conn.get("#{uri.path}?#{uri.query}")
113
+ end
114
+ rescue Errno::ECONNREFUSED => e
115
+ log.warn "CAS server did not respond! (#{e.inspect})"
116
+ return false
117
+ end
118
+
119
+ log.debug "CAS server responded with #{raw_res.inspect}:\n#{raw_res.body}"
120
+
121
+ return raw_res.kind_of?(Net::HTTPSuccess)
122
+ end
123
+
124
+ # Requests a login using the given credentials for the given service;
125
+ # returns a LoginResponse object.
126
+ def login_to_service(credentials, service)
127
+ lt = request_login_ticket
128
+
129
+ data = credentials.merge(
130
+ :lt => lt,
131
+ :service => service
132
+ )
133
+
134
+ res = submit_data_to_cas(login_url, data)
135
+ CASClient::LoginResponse.new(res)
136
+ end
137
+
138
+ # Requests a login ticket from the CAS server for use in a login request;
139
+ # returns a LoginTicket object.
140
+ #
141
+ # This only works with RubyCAS-Server, since obtaining login
142
+ # tickets in this manner is not part of the official CAS spec.
143
+ def request_login_ticket
144
+ uri = URI.parse(login_url+'Ticket')
145
+ https = Net::HTTP.new(uri.host, uri.port)
146
+ https.use_ssl = (uri.scheme == 'https')
147
+ res = https.post(uri.path, ';')
148
+
149
+ raise CASException, res.body unless res.kind_of? Net::HTTPSuccess
150
+
151
+ res.body.strip
152
+ end
153
+
154
+ # Requests a proxy ticket from the CAS server for the given service
155
+ # using the given pgt (proxy granting ticket); returns a ProxyTicket
156
+ # object.
157
+ #
158
+ # The pgt required to request a proxy ticket is obtained as part of
159
+ # a ValidationResponse.
160
+ def request_proxy_ticket(pgt, target_service)
161
+ uri = URI.parse(proxy_url)
162
+ h = uri.query ? query_to_hash(uri.query) : {}
163
+ h['pgt'] = pgt.ticket
164
+ h['targetService'] = target_service
165
+ uri.query = hash_to_query(h)
166
+
167
+ pr = request_cas_response(uri, ProxyResponse)
168
+
169
+ pt = ProxyTicket.new(pr.proxy_ticket, target_service)
170
+ pt.response = pr
171
+
172
+ return pt
173
+ end
174
+
175
+ def retrieve_proxy_granting_ticket(pgt_iou)
176
+ uri = URI.parse(proxy_retrieval_url)
177
+ uri.query = (uri.query ? uri.query + "&" : "") + "pgtIou=#{CGI.escape(pgt_iou)}"
178
+ retrieve_url = uri.to_s
179
+
180
+ log.debug "Retrieving PGT for PGT IOU #{pgt_iou.inspect} from #{retrieve_url.inspect}"
181
+
182
+ # https = Net::HTTP.new(uri.host, uri.port)
183
+ # https.use_ssl = (uri.scheme == 'https')
184
+ # res = https.post(uri.path, ';')
185
+ uri = URI.parse(uri) unless uri.kind_of? URI
186
+ https = Net::HTTP.new(uri.host, uri.port)
187
+ https.use_ssl = (uri.scheme == 'https')
188
+ res = https.start do |conn|
189
+ conn.get("#{uri.path}?#{uri.query}")
190
+ end
191
+
192
+
193
+ raise CASException, res.body unless res.kind_of? Net::HTTPSuccess
194
+
195
+ ProxyGrantingTicket.new(res.body.strip, pgt_iou)
196
+ end
197
+
198
+ def add_service_to_login_url(service_url)
199
+ uri = URI.parse(login_url)
200
+ uri.query = (uri.query ? uri.query + "&" : "") + "service=#{CGI.escape(service_url)}"
201
+ uri.to_s
202
+ end
203
+
204
+ private
205
+ # Fetches a CAS response of the given type from the given URI.
206
+ # Type should be either ValidationResponse or ProxyResponse.
207
+ def request_cas_response(uri, type)
208
+ log.debug "Requesting CAS response for URI #{uri}"
209
+
210
+ uri = URI.parse(uri) unless uri.kind_of? URI
211
+ https = Net::HTTP.new(uri.host, uri.port)
212
+ https.use_ssl = (uri.scheme == 'https')
213
+
214
+ begin
215
+ raw_res = https.start do |conn|
216
+ conn.get("#{uri.path}?#{uri.query}")
217
+ end
218
+ rescue Errno::ECONNREFUSED => e
219
+ log.error "CAS server did not respond! (#{e.inspect})"
220
+ raise "The CAS authentication server at #{uri} is not responding!"
221
+ end
222
+
223
+ # We accept responses of type 422 since RubyCAS-Server generates these
224
+ # in response to requests from the client that are processable but contain
225
+ # invalid CAS data (for example an invalid service ticket).
226
+ if raw_res.kind_of?(Net::HTTPSuccess) || raw_res.code.to_i == 422
227
+ log.debug "CAS server responded with #{raw_res.inspect}:\n#{raw_res.body}"
228
+ else
229
+ log.error "CAS server responded with an error! (#{raw_res.inspect})"
230
+ raise "The CAS authentication server at #{uri} responded with an error (#{raw_res.inspect})!"
231
+ end
232
+
233
+ type.new(raw_res.body)
234
+ end
235
+
236
+ # Submits some data to the given URI and returns a Net::HTTPResponse.
237
+ def submit_data_to_cas(uri, data)
238
+ uri = URI.parse(uri) unless uri.kind_of? URI
239
+ req = Net::HTTP::Post.new(uri.path)
240
+ req.set_form_data(data, ';')
241
+ https = Net::HTTP.new(uri.host, uri.port)
242
+ https.use_ssl = (uri.scheme == 'https')
243
+ https.start {|conn| conn.request(req) }
244
+ end
245
+
246
+ def query_to_hash(query)
247
+ CGI.parse(query)
248
+ end
249
+
250
+ def hash_to_query(hash)
251
+ pairs = []
252
+ hash.each do |k, vals|
253
+ vals = [vals] unless vals.kind_of? Array
254
+ vals.each {|v| pairs << "#{CGI.escape(k)}=#{CGI.escape(v)}"}
255
+ end
256
+ pairs.join("&")
257
+ end
258
+ end
259
+ 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