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,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