diff --git a/.gitignore b/.gitignore index 2465b90..05de119 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ Gemfile.lock /gemfiles/*.lock pkg/* +.rvmrc +.idea \ No newline at end of file diff --git a/lib/generators/proxy_granting_ticket_ious_migration_generator.rb b/lib/generators/proxy_granting_ticket_ious_migration_generator.rb new file mode 100644 index 0000000..a789e26 --- /dev/null +++ b/lib/generators/proxy_granting_ticket_ious_migration_generator.rb @@ -0,0 +1,26 @@ +require 'rails/generators' +require 'rails/generators/migration' + +class ProxyGrantingTicketIousMigrationGenerator < Rails::Generators::Base + include Rails::Generators::Migration + + desc 'Creates a new Proxy Granting Ticket IOUs migration file' + + def self.source_root + File.expand_path('../templates', __FILE__) + end + + def self.next_migration_number(dirname) + if ActiveRecord::Base.timestamped_migrations + migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i + migration_number += 1 + migration_number.to_s + else + "%.3d" % (current_migration_number(dirname) + 1) + end + end + + def create_migration_file + migration_template 'create_rack_cas_proxy_granting_ticket_ious_migration.rb', 'db/migrate/create_rack_cas_proxy_granting_ticket_ious.rb' + end +end diff --git a/lib/generators/templates/create_rack_cas_proxy_granting_ticket_ious_migration.rb b/lib/generators/templates/create_rack_cas_proxy_granting_ticket_ious_migration.rb new file mode 100644 index 0000000..97275d2 --- /dev/null +++ b/lib/generators/templates/create_rack_cas_proxy_granting_ticket_ious_migration.rb @@ -0,0 +1,17 @@ +class CreateRackCasProxyGrantingTicketIous < ActiveRecord::Migration + def self.up + create_table :proxy_granting_ticket_ious do |t| + t.string :proxy_granting_ticket_iou, :null => false + t.string :proxy_granting_ticket, :null => false + t.timestamps + end + + add_index :proxy_granting_ticket_ious, :proxy_granting_ticket_iou + add_index :proxy_granting_ticket_ious, :proxy_granting_ticket + add_index :proxy_granting_ticket_ious, :updated_at + end + + def self.down + drop_table :proxy_granting_ticket_ious + end +end \ No newline at end of file diff --git a/lib/rack-cas/cas_request.rb b/lib/rack-cas/cas_request.rb index 0db1acd..8d10634 100644 --- a/lib/rack-cas/cas_request.rb +++ b/lib/rack-cas/cas_request.rb @@ -1,3 +1,4 @@ +require 'addressable/uri' require 'nokogiri' class CASRequest @@ -29,10 +30,15 @@ def single_sign_out? def ticket_validation? # The CAS protocol specifies that services must support tickets of - # *up to* 32 characters in length (including ST-), and recommendes + # *up to* 32 characters in length (including ST-), and recommends # that services accept tickets up to 256 characters long. - # http://www.jasig.org/cas/protocol - !!(@request.get? && ticket_param && ticket_param.to_s =~ /\AST\-[^\s]{1,253}\Z/) + # + # It also specifies that although the service ticket MUST start with "ST-", + # the proxy ticket SHOULD start with "PT-". The "ST-" validation has + # been moved to the validate_service_url method in server.rb. + # + # http://jasig.github.io/cas/development/protocol/CAS-Protocol-Specification.html + !!(@request.get? && ticket_param && ticket_param.to_s =~ /\A[^\s]{1,256}\Z/) end def path_matches?(strings_or_regexps) @@ -45,12 +51,35 @@ def path_matches?(strings_or_regexps) end end + def pgt_callback? + !!(@request.get? && RackCAS.config.pgt_callback_url? && \ + Addressable::URI.parse(RackCAS.config.pgt_callback_url).path == Addressable::URI.parse(@request.url).path && \ + pgt_iou_param && pgt_iou_param.to_s =~ /\A[^\s]{1,256}\Z/ && \ + pgt_param && pgt_param.to_s =~ /\A[^\s]{1,256}\Z/) + end + + def pgt + pgt_param if pgt_callback? + end + + def pgt_iou + pgt_iou_param if pgt_callback? + end + private def ticket_param @request.params['ticket'] end + def pgt_iou_param + @request.params['pgtIou'] + end + + def pgt_param + @request.params['pgtId'] + end + def sso_ticket xml = Nokogiri::XML(@request.params['logoutRequest']) node = xml.root.children.find { |c| c.name =~ /SessionIndex/i } diff --git a/lib/rack-cas/configuration.rb b/lib/rack-cas/configuration.rb index 1c7f7bf..ab22a57 100644 --- a/lib/rack-cas/configuration.rb +++ b/lib/rack-cas/configuration.rb @@ -1,8 +1,8 @@ module RackCAS class Configuration SETTINGS = [:fake, :server_url, :session_store, :exclude_path, :exclude_paths, :extra_attributes_filter, - :verify_ssl_cert, :renew, :use_saml_validation, :ignore_intercept_validator, :exclude_request_validator, :protocol] - + :verify_ssl_cert, :renew, :use_saml_validation, :ignore_intercept_validator, :exclude_request_validator, + :protocol, :pgt_callback_url] SETTINGS.each do |setting| attr_accessor setting diff --git a/lib/rack-cas/proxy_response.rb b/lib/rack-cas/proxy_response.rb new file mode 100644 index 0000000..4001a16 --- /dev/null +++ b/lib/rack-cas/proxy_response.rb @@ -0,0 +1,79 @@ +require 'nokogiri' + +module RackCAS + class ProxyResponse + class ProxyFailure < StandardError; end + class RequestInvalidError < ProxyFailure; end + class UnauthorizedServiceError < ProxyFailure; end + class InternalError < ProxyFailure; end + + REQUEST_HEADERS = { 'Accept' => '*/*' } + + def initialize(url) + @url = URL.parse(url) + end + + def proxy_ticket + if success? + xml.xpath('/cas:serviceResponse/cas:proxySuccess/cas:proxyTicket').text + else + case failure_code + when 'INVALID_REQUEST' + raise RequestInvalidError, failure_message + when 'UNAUTHORIZED_SERVICE' + raise UnauthorizedServiceError, failure_message + when 'INTERNAL_ERROR' + raise InternalError, failure_message + else + raise ProxyFailure, failure_message + end + end + end + + protected + + def success? + @success ||= !!xml.at('/cas:serviceResponse/cas:proxySuccess') + end + + def proxy_failure + @proxy_failure ||= xml.at('/cas:serviceResponse/cas:proxyFailure') + end + + def failure_message + if proxy_failure + proxy_failure.text.strip + end + end + + def failure_code + if proxy_failure + proxy_failure['code'] + end + end + + def response + require 'net/http' + return @response unless @response.nil? + + http = Net::HTTP.new(@url.host, @url.inferred_port) + if @url.scheme == 'https' + http.use_ssl = true + http.verify_mode = RackCAS.config.verify_ssl_cert? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE + end + + http.start do |conn| + @response = conn.get(@url.request_uri, REQUEST_HEADERS) + end + + @response + end + + def xml + return @xml unless @xml.nil? + + @xml = Nokogiri::XML(response.body) + end + + end +end \ No newline at end of file diff --git a/lib/rack-cas/proxy_ticket_generator.rb b/lib/rack-cas/proxy_ticket_generator.rb new file mode 100644 index 0000000..d7c03c6 --- /dev/null +++ b/lib/rack-cas/proxy_ticket_generator.rb @@ -0,0 +1,24 @@ +require 'rack-cas/url' +require 'rack-cas/proxy_response' + +module RackCAS + class ProxyTicketGenerator + + def self.generate(service_url, pgt) + response = ProxyResponse.new proxy_url(service_url, pgt) + response.proxy_ticket + end + + private + + def self.proxy_url(service_url, pgt) + service_url = URL.parse(service_url).remove_param('ticket').to_s + server_url = RackCAS::URL.parse(RackCAS.config.server_url) + server_url.dup.tap do |url| + url.append_path('proxy') + url.add_params(targetService: service_url, pgt: pgt) + end + end + + end +end \ No newline at end of file diff --git a/lib/rack-cas/server.rb b/lib/rack-cas/server.rb index 2679e39..b43cb93 100644 --- a/lib/rack-cas/server.rb +++ b/lib/rack-cas/server.rb @@ -23,13 +23,17 @@ def logout_url(params = {}) end end - def validate_service(service_url, ticket) + def validate_service(service_url, ticket, pgt_url = RackCAS.config.pgt_callback_url) + pgt_iou = nil unless RackCAS.config.use_saml_validation? - response = ServiceValidationResponse.new validate_service_url(service_url, ticket) + response = ServiceValidationResponse.new validate_service_url(service_url, ticket, pgt_url) + if !!pgt_url + pgt_iou = response.proxy_granting_ticket_iou + end else response = SAMLValidationResponse.new saml_validate_url(service_url), ticket end - [response.user, response.extra_attributes] + [response.user, response.extra_attributes, pgt_iou] end protected @@ -39,9 +43,17 @@ def saml_validate_url(service_url) @url.dup.append_path(path_for_protocol('samlValidate')).add_params(TARGET: service_url) end - def validate_service_url(service_url, ticket) + def validate_service_url(service_url, ticket, pgt_url = RackCAS.config.pgt_callback_url) service_url = URL.parse(service_url).remove_param('ticket').to_s - @url.dup.append_path(path_for_protocol('serviceValidate')).add_params(service: service_url, ticket: ticket) + @url.dup.tap do |url| + if ticket =~ /\AST\-[^\s]{1,253}\Z/ + url.append_path(path_for_protocol('serviceValidate')) + else + url.append_path(path_for_protocol('proxyValidate')) + end + url.add_params(service: service_url, ticket: ticket) + url.add_params(pgtUrl: pgt_url) if pgt_url + end end def path_for_protocol(path) @@ -51,5 +63,6 @@ def path_for_protocol(path) path end end + end end diff --git a/lib/rack-cas/service_validation_response.rb b/lib/rack-cas/service_validation_response.rb index e5e2d9f..3aba8f1 100644 --- a/lib/rack-cas/service_validation_response.rb +++ b/lib/rack-cas/service_validation_response.rb @@ -52,6 +52,12 @@ def extra_attributes attrs end + def proxy_granting_ticket_iou + if success? + @proxy_granting_ticket_iou ||= xml.at('//serviceResponse/authenticationSuccess/proxyGrantingTicket').text + end + end + protected def success? diff --git a/lib/rack-cas/session_store/active_record.rb b/lib/rack-cas/session_store/active_record.rb index d80bc04..af8a031 100644 --- a/lib/rack-cas/session_store/active_record.rb +++ b/lib/rack-cas/session_store/active_record.rb @@ -1,8 +1,20 @@ module RackCAS module ActiveRecordStore + class ProxyGrantingTicketIou < ActiveRecord::Base + end + class Session < ActiveRecord::Base end + def self.create_proxy_granting_ticket(pgt_iou, pgt) + ProxyGrantingTicketIou.create!(proxy_granting_ticket_iou: pgt_iou, proxy_granting_ticket: pgt) + end + + def self.proxy_granting_ticket_for(pgt_iou) + proxy_granting_ticket_iou = ProxyGrantingTicketIou.where(proxy_granting_ticket_iou: pgt_iou).first || nil + proxy_granting_ticket_iou.proxy_granting_ticket if proxy_granting_ticket_iou + end + def self.destroy_session_by_cas_ticket(cas_ticket) affected = Session.delete_all(cas_ticket: cas_ticket) affected == 1 @@ -11,6 +23,7 @@ def self.destroy_session_by_cas_ticket(cas_ticket) def self.prune(after = nil) after ||= Time.now - 2592000 # 30 days ago Session.where('updated_at < ?', after).delete_all + ProxyGrantingTicketIou.where('updated_at < ?', after).delete_all end private diff --git a/lib/rack-cas/session_store/mongoid.rb b/lib/rack-cas/session_store/mongoid.rb index 3cd1cff..747c0d7 100644 --- a/lib/rack-cas/session_store/mongoid.rb +++ b/lib/rack-cas/session_store/mongoid.rb @@ -1,5 +1,15 @@ module RackCAS module MongoidStore + + class ProxyGrantingTicketIou + include Mongoid::Document + include Mongoid::Timestamps + + field :_id, type: String + field :proxy_granting_ticket_iou, type: String + field :proxy_granting_ticket, type: String + end + class Session include Mongoid::Document include Mongoid::Timestamps @@ -15,6 +25,17 @@ class Session field :cas_ticket, type: String end + def self.create_proxy_granting_ticket(pgt_iou, pgt) + ProxyGrantingTicketIou.create!(proxy_granting_ticket_iou: pgt_iou, proxy_granting_ticket: pgt) + end + + def self.proxy_granting_ticket_for(pgt_iou = nil) + if pgt_iou + proxy_granting_ticket_iou = ProxyGrantingTicketIou.where(proxy_granting_ticket_iou: pgt_iou).first || nil + proxy_granting_ticket_iou.proxy_granting_ticket if proxy_granting_ticket_iou + end + end + def self.destroy_session_by_cas_ticket(cas_ticket) affected = Session.where(cas_ticket: cas_ticket).delete affected == 1 @@ -23,6 +44,7 @@ def self.destroy_session_by_cas_ticket(cas_ticket) def self.prune(after = nil) after ||= Time.now - 2592000 # 30 days ago Session.where(:updated_at.lte => after).delete + ProxyGrantingTicketIou.where(:updated_at.lte => after).delete end private diff --git a/lib/rack-cas/url.rb b/lib/rack-cas/url.rb index 01bd455..b8ff898 100644 --- a/lib/rack-cas/url.rb +++ b/lib/rack-cas/url.rb @@ -7,7 +7,7 @@ def self.parse(uri) # Addressable to replace + spaces with %20 spaces. Standardizing on %20 # should prevent service lookup issues due to encoding differences. super.tap do |u| - u.query_values = u.query_values + u.query_values = u.query_values(Array) end end @@ -19,9 +19,9 @@ def append_path(path) def add_params(params) self.tap do |u| - u.query_values = (u.query_values || {}).tap do |qv| + u.query_values = (u.query_values(Array) || []).tap do |qv| params.each do |key, value| - qv[key] = value + qv << [key, value] end end end @@ -34,9 +34,9 @@ def remove_param(param) # params can be an array or a hash def remove_params(params) self.tap do |u| - u.query_values = (u.query_values || {}).tap do |qv| + u.query_values = (u.query_values(Array) || []).tap do |qv| params.each do |key, value| - qv.delete key + qv.reject! { |param| param.first == key } end end if u.query_values.empty? diff --git a/lib/rack/cas.rb b/lib/rack/cas.rb index 232d125..125e36b 100644 --- a/lib/rack/cas.rb +++ b/lib/rack/cas.rb @@ -22,17 +22,23 @@ def call(env) log env, 'rack-cas: Intercepting ticket validation request.' begin - user, extra_attrs = get_user(request.url, cas_request.ticket) + user, extra_attrs, pgt_iou = get_user(request.url, cas_request.ticket) rescue RackCAS::ServiceValidationResponse::TicketInvalidError, RackCAS::SAMLValidationResponse::TicketInvalidError log env, 'rack-cas: Invalid ticket. Redirecting to CAS login.' return redirect_to server.login_url(cas_request.service_url).to_s end - store_session request, user, cas_request.ticket, extra_attrs + store_session request, user, cas_request.ticket, extra_attrs, pgt_iou return redirect_to cas_request.service_url end + if cas_request.pgt_callback? + log env, 'rack-cas: Intercepting proxy granting ticket callback request.' + RackCAS.config.session_store.create_proxy_granting_ticket(cas_request.pgt_iou, cas_request.pgt) + return [200, {'Content-Type' => 'text/plain'}, ['CAS proxy granting ticket created successfully.']] + end + if cas_request.logout? log env, 'rack-cas: Intercepting logout request.' @@ -78,15 +84,21 @@ def exclude_request?(cas_request) end def get_user(service_url, ticket) - server.validate_service(service_url, ticket) + server.validate_service(service_url, ticket, RackCAS.config.pgt_callback_url) end - def store_session(request, user, ticket, extra_attrs = {}) + def store_session(request, user, ticket, extra_attrs = {}, pgt_iou = nil) if RackCAS.config.extra_attributes_filter? extra_attrs.select! { |key, val| RackCAS.config.extra_attributes_filter.map(&:to_s).include? key.to_s } end request.session['cas'] = { 'user' => user, 'ticket' => ticket, 'extra_attributes' => extra_attrs } + + if pgt_iou + pgt = RackCAS.config.session_store.proxy_granting_ticket_for(pgt_iou) + request.session['cas']['pgt'] = pgt if pgt + end + end def redirect_to(url, status=302) diff --git a/rack-cas.gemspec b/rack-cas.gemspec index ddf629a..c77bce8 100644 --- a/rack-cas.gemspec +++ b/rack-cas.gemspec @@ -15,7 +15,7 @@ spec = Gem::Specification.new do |s| s.homepage = 'https://github.com/biola/rack-cas' s.license = 'MIT' s.add_dependency 'rack', '>= 1.3' - s.add_dependency 'addressable', '~> 2.3' + s.add_dependency 'addressable', '~> 2' s.add_dependency 'nokogiri', '~> 1.5' s.add_development_dependency 'rspec', '~> 3.2' s.add_development_dependency 'rspec-its', '~> 1.0' diff --git a/spec/fixtures/jasig_service_response.xml b/spec/fixtures/jasig_service_response.xml index 215b0eb..a6b917b 100644 --- a/spec/fixtures/jasig_service_response.xml +++ b/spec/fixtures/jasig_service_response.xml @@ -16,5 +16,6 @@ CN=Support,OU=Departments,DC=example,DC=com CN=Testing,OU=Departments,DC=example,DC=com + PGTIOU-1234567890 diff --git a/spec/fixtures/rubycas_service_response.xml b/spec/fixtures/rubycas_service_response.xml index 6d4abda..12f1181 100644 --- a/spec/fixtures/rubycas_service_response.xml +++ b/spec/fixtures/rubycas_service_response.xml @@ -58,5 +58,6 @@ - CN=Employees,OU=Security Groups,DC=example,DC=com ]]> + PGTIOU-1234567890 \ No newline at end of file diff --git a/spec/rack-cas/cas_request_spec.rb b/spec/rack-cas/cas_request_spec.rb index 63e2aa3..f8709e8 100644 --- a/spec/rack-cas/cas_request_spec.rb +++ b/spec/rack-cas/cas_request_spec.rb @@ -24,13 +24,13 @@ def app end context 'invalid ticket' do - before { get '/private/something?ticket=BLARG' } + before { get '/private/something?ticket=BL+ARG' } its(:ticket_validation?) { should be false } its(:ticket) { should be nil } end context 'short ticket' do - before { get '/private/something?ticket=ST-' } + before { get '/private/something?ticket=' } its(:ticket_validation?) { should be false } its(:ticket) { should be nil } end diff --git a/spec/rack-cas/configuration_spec.rb b/spec/rack-cas/configuration_spec.rb index f4c7725..67463e0 100644 --- a/spec/rack-cas/configuration_spec.rb +++ b/spec/rack-cas/configuration_spec.rb @@ -2,6 +2,7 @@ require 'rack-cas/configuration' describe RackCAS::Configuration do + subject { RackCAS::Configuration.new } context 'when the attribute is neither nil, an empty array, nor false' do diff --git a/spec/rack-cas/server_spec.rb b/spec/rack-cas/server_spec.rb index bf9bf11..5670b4d 100644 --- a/spec/rack-cas/server_spec.rb +++ b/spec/rack-cas/server_spec.rb @@ -15,13 +15,13 @@ context 'with params' do subject { server.login_url(service_url, gateway: 'true') } - its(:to_s) { should eql 'http://example.com/cas/login?gateway=true&service=http%3A%2F%2Fexample.org%2Fwhatever' } + its(:to_s) { should eql 'http://example.com/cas/login?service=http%3A%2F%2Fexample.org%2Fwhatever&gateway=true' } end context 'with renew = true' do before { RackCAS.config.stub(renew: true) } subject { server.login_url(service_url) } - its(:to_s) { should eql 'http://example.com/cas/login?renew=true&service=http%3A%2F%2Fexample.org%2Fwhatever' } + its(:to_s) { should eql 'http://example.com/cas/login?service=http%3A%2F%2Fexample.org%2Fwhatever&renew=true' } end end @@ -42,11 +42,19 @@ end end - describe :validate_service do + describe :validate_service_without_pgt_url do subject { server.validate_service(service_url, ticket) } - its(:length) { should eql 2 } + its(:length) { should eql 3 } its(:first) { should eql 'johnd0' } - its(:last) { should be_kind_of Hash } + its(:last) { should be nil } + end + + describe :validate_service_with_pgt_url do + + subject { server.validate_service(service_url, ticket, 'http://example.com/callback') } + its(:length) { should eql 3 } + its(:first) { should eql 'johnd0' } + its(:last) { should eql 'PGTIOU-1234567890' } end describe :validate_service_url do diff --git a/spec/rack-cas/url_spec.rb b/spec/rack-cas/url_spec.rb index 2054417..6a6775f 100644 --- a/spec/rack-cas/url_spec.rb +++ b/spec/rack-cas/url_spec.rb @@ -19,7 +19,7 @@ describe :add_params do subject { url.add_params(appended: 'param') } - its(:query) { should eql 'appended=param¶m1=value1¶m2=value2' } + its(:query) { should eql 'param1=value1¶m2=value2&appended=param' } end describe :remove_param do