jorahood-rubycas-client 2.0.99.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,211 @@
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
+ # service_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 this only works with RubyCAS-Server.
50
+ # back_url:: This satisfies section 2.3.1 of the CAS protocol spec.
51
+ # See http://www.ja-sig.org/products/cas/overview/protocol
52
+ def logout_url(service_url = nil, back_url = nil)
53
+ url = @logout_url || (cas_base_url + "/logout")
54
+
55
+ if service_url || back_url
56
+ uri = URI.parse(url)
57
+ h = uri.query ? query_to_hash(uri.query) : {}
58
+ h['service'] = service_url if service_url
59
+ h['url'] = back_url if back_url
60
+ uri.query = hash_to_query(h)
61
+ uri.to_s
62
+ else
63
+ url
64
+ end
65
+ end
66
+
67
+ def proxy_url
68
+ @proxy_url || (cas_base_url + "/proxy")
69
+ end
70
+
71
+ def validate_service_ticket(st)
72
+ uri = URI.parse(validate_url)
73
+ h = uri.query ? query_to_hash(uri.query) : {}
74
+ h['casurl'] = st.service
75
+ h['casticket'] = st.ticket
76
+ h['renew'] = 1 if st.renew
77
+ h['pgtUrl'] = proxy_callback_url if proxy_callback_url
78
+ uri.query = hash_to_query(h)
79
+
80
+ st.response = request_cas_response(uri, ValidationResponse)
81
+
82
+ return st
83
+ end
84
+ alias validate_proxy_ticket validate_service_ticket
85
+
86
+ # Requests a login using the given credentials for the given service;
87
+ # returns a LoginResponse object.
88
+ def login_to_service(credentials, service)
89
+ lt = request_login_ticket
90
+
91
+ data = credentials.merge(
92
+ :lt => lt,
93
+ :service => service
94
+ )
95
+
96
+ res = submit_data_to_cas(login_url, data)
97
+ CASClient::LoginResponse.new(res)
98
+ end
99
+
100
+ # Requests a login ticket from the CAS server for use in a login request;
101
+ # returns a LoginTicket object.
102
+ #
103
+ # This only works with RubyCAS-Server, since obtaining login
104
+ # tickets in this manner is not part of the official CAS spec.
105
+ def request_login_ticket
106
+ uri = URI.parse(login_url+'Ticket')
107
+ https = Net::HTTP.new(uri.host, uri.port)
108
+ https.use_ssl = (uri.scheme == 'https')
109
+ res = https.post(uri.path, ';')
110
+
111
+ raise CASException, res.body unless res.kind_of? Net::HTTPSuccess
112
+
113
+ res.body.strip
114
+ end
115
+
116
+ # Requests a proxy ticket from the CAS server for the given service
117
+ # using the given pgt (proxy granting ticket); returns a ProxyTicket
118
+ # object.
119
+ #
120
+ # The pgt required to request a proxy ticket is obtained as part of
121
+ # a ValidationResponse.
122
+ def request_proxy_ticket(pgt, target_service)
123
+ uri = URI.parse(proxy_url)
124
+ h = uri.query ? query_to_hash(uri.query) : {}
125
+ h['pgt'] = pgt.ticket
126
+ h['targetService'] = target_service
127
+ uri.query = hash_to_query(h)
128
+
129
+ pr = request_cas_response(uri, ProxyResponse)
130
+
131
+ pt = ProxyTicket.new(pr.proxy_ticket, target_service)
132
+ pt.response = pr
133
+
134
+ return pt
135
+ end
136
+
137
+ def retrieve_proxy_granting_ticket(pgt_iou)
138
+ uri = URI.parse(proxy_retrieval_url)
139
+ uri.query = (uri.query ? uri.query + "&" : "") + "pgtIou=#{CGI.escape(pgt_iou)}"
140
+ retrieve_url = uri.to_s
141
+
142
+ log.debug "Retrieving PGT for PGT IOU #{pgt_iou.inspect} from #{retrieve_url.inspect}"
143
+
144
+ # https = Net::HTTP.new(uri.host, uri.port)
145
+ # https.use_ssl = (uri.scheme == 'https')
146
+ # res = https.post(uri.path, ';')
147
+ uri = URI.parse(uri) unless uri.kind_of? URI
148
+ https = Net::HTTP.new(uri.host, uri.port)
149
+ https.use_ssl = (uri.scheme == 'https')
150
+ res = https.start do |conn|
151
+ conn.get("#{uri.path}?#{uri.query}")
152
+ end
153
+
154
+
155
+ raise CASException, res.body unless res.kind_of? Net::HTTPSuccess
156
+
157
+ ProxyGrantingTicket.new(res.body.strip, pgt_iou)
158
+ end
159
+
160
+ def add_service_to_login_url(service_url)
161
+ uri = URI.parse(login_url)
162
+ # IU's CAS server can't deal with escaped redirects, and the param has to be named "casurl" not "service"
163
+ uri.query = (uri.query ? uri.query + "&" : "") + "casurl=#{service_url}"
164
+ uri.to_s
165
+ end
166
+
167
+ private
168
+ # Fetches a CAS response of the given type from the given URI.
169
+ # Type should be either ValidationResponse or ProxyResponse.
170
+ def request_cas_response(uri, type)
171
+ log.debug "Requesting CAS response form URI #{uri.inspect}"
172
+
173
+ uri = URI.parse(uri) unless uri.kind_of? URI
174
+ https = Net::HTTP.new(uri.host, uri.port)
175
+ https.use_ssl = (uri.scheme == 'https')
176
+ raw_res = https.start do |conn|
177
+ conn.get("#{uri.path}?#{uri.query}")
178
+ end
179
+
180
+ #TODO: check to make sure that response code is 200 and handle errors otherwise
181
+
182
+ log.debug "CAS Responded with #{raw_res.inspect}:\n#{raw_res.body}"
183
+
184
+ type.new(raw_res.body)
185
+ end
186
+
187
+ # Submits some data to the given URI and returns a Net::HTTPResponse.
188
+ def submit_data_to_cas(uri, data)
189
+ uri = URI.parse(uri) unless uri.kind_of? URI
190
+ req = Net::HTTP::Post.new(uri.path)
191
+ req.set_form_data(data, ';')
192
+ https = Net::HTTP.new(uri.host, uri.port)
193
+ https.use_ssl = (uri.scheme == 'https')
194
+ https.start {|conn| conn.request(req) }
195
+ end
196
+
197
+ def query_to_hash(query)
198
+ CGI.parse(query)
199
+ end
200
+
201
+ def hash_to_query(hash)
202
+ pairs = []
203
+ hash.each do |k, vals|
204
+ vals = [vals] unless vals.kind_of? Array
205
+ # IU's CAS server doesn't accept escaped service URLs
206
+ vals.each {|v| pairs << "#{CGI.escape(k)}=#{v}"}
207
+ end
208
+ pairs.join("&")
209
+ end
210
+ end
211
+ 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,195 @@
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
+
12
+ class << self
13
+ def filter(controller)
14
+ raise "Cannot use the CASClient filter because it has not yet been configured." if config.nil?
15
+
16
+ st = read_ticket(controller)
17
+
18
+ lst = controller.session[:cas_last_valid_ticket]
19
+
20
+ if st && lst && lst.ticket == st.ticket && lst.service == st.service
21
+ # warn() rather than info() because we really shouldn't be re-validating the same ticket.
22
+ # The only time when this is acceptable is if the user manually does a refresh and the ticket
23
+ # happens to be in the URL.
24
+ log.warn("Re-using previously validated ticket since the new ticket and service are the same.")
25
+ st = lst
26
+ end
27
+
28
+ if st
29
+ client.validate_service_ticket(st) unless st.has_been_validated?
30
+ vr = st.response
31
+
32
+ if st.is_valid?
33
+ log.info("Ticket #{st.ticket.inspect} for service #{st.service.inspect} belonging to user #{vr.user.inspect} is VALID.")
34
+ controller.session[client.username_session_key] = vr.user
35
+ controller.session[client.extra_attributes_session_key] = vr.extra_attributes
36
+
37
+ # RubyCAS-Client 1.x used :casfilteruser as it's username session key,
38
+ # so we need to set this here to ensure compatibility with configurations
39
+ # built around the old client.
40
+ controller.session[:casfilteruser] = vr.user
41
+
42
+ # Store the ticket in the session to avoid re-validating the same service
43
+ # ticket with the CAS server.
44
+ controller.session[:cas_last_valid_ticket] = st
45
+
46
+ if vr.pgt_iou
47
+ log.info("Receipt has a proxy-granting ticket IOU. Attempting to retrieve the proxy-granting ticket...")
48
+ pgt = client.retrieve_proxy_granting_ticket(vr.pgt_iou)
49
+ if pgt
50
+ log.debug("Got PGT #{pgt.ticket.inspect} for PGT IOU #{pgt.iou.inspect}. This will be stored in the session.")
51
+ controller.session[:cas_pgt] = pgt
52
+ # For backwards compatibility with RubyCAS-Client 1.x configurations...
53
+ controller.session[:casfilterpgt] = pgt
54
+ else
55
+ log.error("Failed to retrieve a PGT for PGT IOU #{vr.pgt_iou}!")
56
+ end
57
+ end
58
+
59
+ return true
60
+ else
61
+ log.warn("Ticket #{st.ticket.inspect} failed validation -- #{vr.failure_code}: #{vr.failure_message}")
62
+ redirect_to_cas_for_authentication(controller)
63
+ return false
64
+ end
65
+ elsif !config[:authenticate_on_every_request] && controller.session[client.username_session_key]
66
+ # Don't re-authenticate with the CAS server if we already previously authenticated and the
67
+ # :authenticate_on_every_request option is disabled (it's disabled by default).
68
+ log.debug "Existing local CAS session detected for #{controller.session[client.username_session_key].inspect}. "+
69
+ "User will not be re-authenticated."
70
+ return true
71
+ else
72
+ if returning_from_gateway?(controller)
73
+ log.info "Returning from CAS gateway without authentication."
74
+
75
+ if use_gatewaying?
76
+ log.info "This CAS client is configured to use gatewaying, so we will permit the user to continue without authentication."
77
+ return true
78
+ else
79
+ log.warn "The CAS client is NOT configured to allow gatewaying, yet this request was gatewayed. Something is not right!"
80
+ end
81
+ end
82
+
83
+ redirect_to_cas_for_authentication(controller)
84
+ return false
85
+ end
86
+ end
87
+
88
+ def configure(config)
89
+ @@config = config
90
+ @@config[:logger] = RAILS_DEFAULT_LOGGER unless @@config[:logger]
91
+ @@client = CASClient::Client.new(config)
92
+ @@log = client.log
93
+ end
94
+
95
+ def use_gatewaying?
96
+ @@config[:use_gatewaying]
97
+ end
98
+
99
+ def returning_from_gateway?(controller)
100
+ controller.session[:cas_sent_to_gateway]
101
+ end
102
+
103
+ def redirect_to_cas_for_authentication(controller)
104
+ service_url = read_service_url(controller)
105
+ redirect_url = client.add_service_to_login_url(service_url)
106
+
107
+ if use_gatewaying?
108
+ controller.session[:cas_sent_to_gateway] = true
109
+ redirect_url << "&gateway=true"
110
+ else
111
+ controller.session[:cas_sent_to_gateway] = false
112
+ end
113
+
114
+ log.debug("Redirecting to #{redirect_url.inspect}")
115
+ controller.send(:redirect_to, redirect_url)
116
+ end
117
+
118
+ private
119
+ def read_ticket(controller)
120
+ ticket = controller.params[:casticket]
121
+
122
+ return nil unless ticket
123
+
124
+ log.debug("Request contains ticket #{ticket.inspect}.")
125
+
126
+ if ticket =~ /^PT-/
127
+ ProxyTicket.new(ticket, read_service_url(controller), controller.params[:renew])
128
+ else
129
+ ServiceTicket.new(ticket, read_service_url(controller), controller.params[:renew])
130
+ end
131
+ end
132
+
133
+ def read_service_url(controller)
134
+ if config[:service_url]
135
+ log.debug("Using explicitly set service url: #{config[:service_url]}")
136
+ return config[:service_url]
137
+ end
138
+
139
+ params = controller.params.dup
140
+ params.delete(:casticket)
141
+ service_url = controller.url_for(params)
142
+ log.debug("Guessed service url: #{service_url.inspect}")
143
+ return service_url
144
+ end
145
+
146
+ # Creates a file in tmp/sessions linking a SessionTicket
147
+ # with the local Rails session id. The file is named
148
+ # cas_sess.<session ticket> and its text contents is the corresponding
149
+ # Rails session id.
150
+ # Returns the filename of the lookup file created.
151
+ def store_service_session_lookup(st, sid)
152
+ st = st.ticket if st.kind_of? ServiceTicket
153
+ f = File.new(filename_of_service_session_lookup(st), 'w')
154
+ f.write(sid)
155
+ f.close
156
+ return f.path
157
+ end
158
+
159
+ # Returns the local Rails session ID corresponding to the given
160
+ # ServiceTicket. This is done by reading the contents of the
161
+ # cas_sess.<session ticket> file created in a prior call to
162
+ # #store_service_session_lookup.
163
+ def read_service_session_lookup(st)
164
+ st = st.ticket if st.kind_of? ServiceTicket
165
+ ssl_filename = filename_of_service_session_lookup(st)
166
+ return File.exists?(ssl_filename) && IO.read(ssl_filename)
167
+ end
168
+
169
+ # Removes a stored relationship between a ServiceTicket and a local
170
+ # Rails session id. This should be called when the session is being
171
+ # closed.
172
+ #
173
+ # See #store_service_session_lookup.
174
+ def delete_service_session_lookup(st)
175
+ st = st.ticket if st.kind_of? ServiceTicket
176
+ ssl_filename = filename_of_service_session_lookup(st)
177
+ File.delete(ssl_filename) if File.exists?(ssl_filename)
178
+ end
179
+
180
+ # Returns the path and filename of the service session lookup file.
181
+ def filename_of_service_session_lookup(st)
182
+ st = st.ticket if st.kind_of? ServiceTicket
183
+ return "#{RAILS_ROOT}/tmp/sessions/cas_sess.#{st}"
184
+ end
185
+ end
186
+ end
187
+
188
+ class GatewayFilter < Filter
189
+ def self.use_gatewaying?
190
+ return true unless @@config[:use_gatewaying] == false
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end