casrack_the_authenticator 1.4.0

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.
data/README.rdoc ADDED
@@ -0,0 +1,87 @@
1
+ Casrack the Authenticator is a
2
+ Rack[http://github.com/chneukirchen/rack] middleware
3
+ that provides CAS[http://www.jasig.org/cas] support.
4
+
5
+ As of the current version, Casrack the Authenticator only supports the most basic
6
+ of CAS scenarios: it requires CAS authentication if it receives a 401 Unauthorized
7
+ response from lower-down in the Rack stack, and it stores the authentication token
8
+ in the session (so logout happens when users close their browers). Casrack the Authenticator
9
+ is a very open-minded beast, though, so please contribute (well-tested) additions to do
10
+ proxy-authentication and single-sign-out, or for anything else you desire.
11
+
12
+ === How-To
13
+
14
+ ==== 1: install
15
+
16
+ [sudo] gem install casrack_the_authenticator
17
+
18
+ ==== 2: set up the middleware:
19
+
20
+ # in your rackup:
21
+ use CasrackTheAuthenticator::Simple, :cas_server => "http://cas.mycompany.com/cas"
22
+ # or "config.middleware.use" if you're on Rails
23
+
24
+ See CasrackTheAuthenticator::Configuration for specifics on that Hash argument.
25
+
26
+ ==== 3: optionally install CasrackTheAuthenticator::RequireCAS if you want _every_ request to require CAS authentication:
27
+
28
+ # in your rackup:
29
+ use CasrackTheAuthenticator::Simple, :cas_server => ...
30
+ use CasrackTheAuthenticator::RequireCAS
31
+ # or "config.middleware.use" if you're on Rails
32
+
33
+ ==== 4: pull the authenticated CAS username out of the Rack session:
34
+
35
+ # in a Rack app:
36
+ def call(env)
37
+ user = cas_user(env)
38
+ ...
39
+ end
40
+
41
+ def cas_user(env)
42
+ username = Rack::Request.new(env).session[CasrackTheAuthenticator::USERNAME_PARAM]
43
+ User.find_by_username(username)
44
+ end
45
+
46
+ # or, in a Rails controller:
47
+
48
+ def cas_user
49
+ username = Rack::Request.new(request.env).session[CasrackTheAuthenticator::USERNAME_PARAM]
50
+ User.find_by_username(username)
51
+ end
52
+
53
+ === Disconnected (Fake) Mode
54
+
55
+ I've often found myself working on a CAS-ified project while away from the office
56
+ and unable to access the CAS server. To support this type of disconnected development,
57
+ just substitute in the CasrackTheAuthenticator::Fake middleware. It acts like
58
+ CasrackTheAuthenticator::Simple, but it uses HTTP Basic authentication against a
59
+ preset list of usernames.
60
+
61
+ A common pattern for Rails apps is to create a disconnected environment:
62
+
63
+ ==== 1: set up <tt>[rails_root]/config/database.yml</tt>
64
+
65
+ development: &DEV
66
+ adapter: sqlite3
67
+ database: db/development.sqlite3
68
+ pool: 5
69
+ timeout: 5000
70
+
71
+ # development mode when disconnected from MITRE
72
+ disconnected:
73
+ <<: *DEV
74
+
75
+ ==== 2: set up <tt>[rails_root]/config/disconnected.rb</tt>:
76
+
77
+ load './development.rb'
78
+ config.middleware.swap 'CasrackTheAuthenticator::Simple', CasrackTheAuthenticator::Fake, 'jimbob', 'sueann'
79
+
80
+ ==== 3: run in disconnected mode:
81
+
82
+ script/server -e disconnected
83
+
84
+ ==== 4: login as 'jimbob' or 'sueann'
85
+
86
+ Passwords are ignored.
87
+
data/Rakefile ADDED
@@ -0,0 +1,78 @@
1
+ desc "Default: run all tests, including features"
2
+ task :default => [ 'test', 'features' ]
3
+
4
+ # PACKAGING
5
+
6
+ require 'rake/gempackagetask'
7
+
8
+ spec = eval File.read('casrack_the_authenticator.gemspec')
9
+
10
+ Rake::GemPackageTask.new(spec) do |p|
11
+ p.gem_spec = spec
12
+ p.need_tar = true
13
+ p.need_zip = true
14
+ end
15
+
16
+ namespace :pkg do
17
+
18
+ pkg_dir = './pkg'
19
+
20
+ desc "clean the pkg/ director"
21
+ task :clean do
22
+ rm_r pkg_dir if File.exists?(pkg_dir)
23
+ end
24
+
25
+ end
26
+
27
+ # DOCUMENTATION
28
+
29
+ require 'yard'
30
+ require 'yard/rake/yardoc_task'
31
+
32
+ desc "Generate RDoc"
33
+ task :doc => ['doc:generate']
34
+
35
+ namespace :doc do
36
+
37
+ doc_dir = './doc/rdoc'
38
+
39
+ YARD::Rake::YardocTask.new(:generate) do |yt|
40
+ yt.files = ['lib/**/*.rb', 'README.rdoc']
41
+ yt.options = ['--output-dir', doc_dir, '--readme', 'README.rdoc']
42
+ end
43
+
44
+ desc "Remove generated documenation"
45
+ task :clean do
46
+ rm_r doc_dir if File.exists?(doc_dir)
47
+ end
48
+
49
+ end
50
+
51
+ # TESTING
52
+
53
+ require 'cucumber'
54
+ require 'cucumber/rake/task'
55
+ require 'rake/testtask'
56
+
57
+ Cucumber::Rake::Task.new(:features) do |t|
58
+ t.cucumber_opts = "features --format pretty"
59
+ end
60
+
61
+ PROJECT_ROOT = File.expand_path(File.dirname(__FILE__))
62
+
63
+ LIB_DIRECTORIES = FileList.new do |fl|
64
+ fl.include "#{PROJECT_ROOT}/lib"
65
+ fl.include "#{PROJECT_ROOT}/test/lib"
66
+ end
67
+
68
+ TEST_FILES = FileList.new do |fl|
69
+ fl.include "#{PROJECT_ROOT}/test/**/*_test.rb"
70
+ fl.exclude "#{PROJECT_ROOT}/test/test_helper.rb"
71
+ fl.exclude "#{PROJECT_ROOT}/test/lib/**/*.rb"
72
+ end
73
+
74
+ Rake::TestTask.new(:test) do |t|
75
+ t.libs = LIB_DIRECTORIES
76
+ t.test_files = TEST_FILES
77
+ t.verbose = true
78
+ end
@@ -0,0 +1,34 @@
1
+ Feature: Fake CAS Authentication
2
+ In order to support developers who work away from the office
3
+ Casrack the Authenticator provides a Fake middleware.
4
+
5
+ Background:
6
+ Given a Rack application exists
7
+ And the fake Casrack middleware is installed with user "missy"
8
+
9
+ Scenario: not-signed-in user makes a request to a public area
10
+ Given the underlying Rack application returns [200, {}, "Public Information"]
11
+ When I make a request
12
+ Then the response should be successful
13
+ And the response body should include "Public Information"
14
+ And the CAS user should be nil
15
+
16
+ Scenario: not-signed-in user makes a request to a private area
17
+ Given the underlying Rack application returns [401, {}, 'Restricted. Go away.']
18
+ When I make a request
19
+ Then I should be presented with a HTTP Basic authentication request
20
+ And the CAS user should be nil
21
+
22
+ Scenario: user presenting invalid credentials makes a request to a private area
23
+ Given the underlying Rack application returns [401, {}, 'Restricted. Go away.']
24
+ When I make a request as "thomas"
25
+ Then I should be presented with a HTTP Basic authentication request
26
+ And the CAS user should be nil
27
+
28
+ Scenario: user presenting credentials makes a request to a private area
29
+ Given the underlying Rack application returns [200, {}, 'Restricted. Shh.']
30
+ When I make a request as "missy"
31
+ Then the response should be successful
32
+ And the response body should include "Restricted. Shh."
33
+ And the CAS user should be "missy"
34
+
@@ -0,0 +1,19 @@
1
+ Feature: Required CAS Authentication
2
+ In order make it dead-simple for users to implement a CAS-login requirement
3
+ Casrack the Authenticator provides a RequireCAS middleware.
4
+
5
+ Background:
6
+ Given a Rack application exists
7
+ And the RequireCAS middleware is installed
8
+ And the simple version of Casrack the Authenticator is installed
9
+ And the underlying Rack application returns [200, {}, "Public Information"]
10
+
11
+ Scenario: not-signed-in user makes a request
12
+ When I make a request
13
+ Then I should be redirected to CAS
14
+ And the "Content-Type" header should be "text/plain"
15
+
16
+ Scenario: signed-in-user makes a request
17
+ When I return to "http://myapp.org/bar" with a valid CAS ticket for "tperon"
18
+ Then the response should be successful
19
+ And the response body should include "Public Information"
@@ -0,0 +1,32 @@
1
+ Feature: Simple CAS Authentication
2
+ In order to maintain privacy and accountability while keeping IT costs low
3
+ "Upper Management" wants to use CAS authentication
4
+
5
+ Background:
6
+ Given a Rack application exists
7
+ And the simple version of Casrack the Authenticator is installed
8
+
9
+ Scenario: not-signed-in user accesses public material
10
+ Given the underlying Rack application returns [200, {}, "Public Information"]
11
+ When I make a request
12
+ Then the response should be successful
13
+ And the response body should include "Public Information"
14
+
15
+ Scenario: not-signed-in user accesses restricted material
16
+ Given the underlying Rack application returns [401, {}, "Restricted!"]
17
+ When I make a request to "http://myapp.com/foo?bar=baz"
18
+ Then I should be redirected to CAS
19
+ And the "Content-Type" header should be "text/plain"
20
+ And CAS should return me to "http://myapp.com/foo?bar=baz"
21
+
22
+ Scenario: returning from a successful CAS sign-in
23
+ Given the underlying Rack application returns [200, {}, "Information for jswanson"]
24
+ When I return to "http://myapp.org/bar" with a valid CAS ticket for "jswanson"
25
+ Then the CAS user should be "jswanson"
26
+ And the response should be successful
27
+ And the response body should include "Information for jswanson"
28
+
29
+ Scenario: returning from an unsuccessful CAS sign-in
30
+ Given the underlying Rack application returns [401, {}, "Restricted!"]
31
+ When I return to "http://myapp.org/bar" with an invalid CAS ticket
32
+ Then the CAS user should be nil
@@ -0,0 +1,14 @@
1
+ Given /^the fake Casrack middleware is installed with user "([^\"]*)"$/ do |user|
2
+ self.app = CasrackTheAuthenticator::Fake.new(app, user)
3
+ end
4
+
5
+ Then /^I should be presented with a HTTP Basic authentication request$/ do
6
+ assert_equal 401, response.status
7
+ assert_equal 'text/plain', response.headers['Content-Type']
8
+ assert_equal '0', response.headers['Content-Length']
9
+ assert_equal 'Basic realm="CasrackTheAuthenticator::Fake"', response.headers['WWW-Authenticate']
10
+ end
11
+
12
+ When /^I make a request as "([^\"]*)"$/ do |username|
13
+ get '/', { 'HTTP_AUTHORIZATION' => 'Basic ' + ["#{username}:sekret"].pack("m*") }
14
+ end
@@ -0,0 +1,69 @@
1
+ require File.join(File.dirname(__FILE__), '..', '..', 'test', 'test_helper.rb')
2
+ require 'rack/mock'
3
+
4
+ Given /^a Rack application exists$/ do
5
+ self.underlying_app = lambda { |env| nil }
6
+ self.app = underlying_app
7
+ end
8
+
9
+ Given /^the simple version of Casrack the Authenticator is installed$/ do
10
+ self.app = CasrackTheAuthenticator::Simple.new(app, :cas_server => 'http://cas.test/cas')
11
+ end
12
+
13
+ Given /^the RequireCAS middleware is installed$/ do
14
+ self.app = CasrackTheAuthenticator::RequireCAS.new(app)
15
+ end
16
+
17
+ Given /^the underlying Rack application returns (.+)$/ do |response|
18
+ underlying_app.stubs(:call).returns(eval(response))
19
+ end
20
+
21
+ When /^I make a request$/ do
22
+ get '/'
23
+ end
24
+
25
+ When /^I make a request to "([^\"]*)"$/ do |url|
26
+ get url
27
+ end
28
+
29
+ When /^I return to "([^\"]*)" with a valid CAS ticket for "([^\"]*)"$/ do |url, user|
30
+ http_request_returns_valid_cas_user user
31
+ url << (url.include?('?') ? '&' : '?') << 'ticket=ST-123455'
32
+ When "I make a request to \"#{url}\""
33
+ end
34
+
35
+ When /^I return to "([^\"]*)" with an invalid CAS ticket$/ do |url|
36
+ http_request_returns_error
37
+ url << (url.include?('?') ? '&' : '?') << 'ticket=ST-not-a-valid-ticket'
38
+ When "I make a request to \"#{url}\""
39
+ end
40
+
41
+ Then /^the CAS user should be nil$/ do
42
+ assert_equal nil, session[:cas_user]
43
+ end
44
+
45
+ Then /^the CAS user should be "([^\"]*)"$/ do |username|
46
+ assert_equal username, session[:cas_user]
47
+ end
48
+
49
+ Then /^the response should be successful$/ do
50
+ assert((200..299).include?(response.status), "Expected success, but was #{response.status}")
51
+ end
52
+
53
+ Then /^the response body should include "([^\"]*)"$/ do |text|
54
+ assert response.body.include?(text)
55
+ end
56
+
57
+ Then /^I should be redirected to CAS$/ do
58
+ assert((300..399).include?(response.status), "Expected redirect, but was #{response.status}")
59
+ assert !redirected_to.nil?
60
+ assert redirected_to.to_s =~ /cas/i
61
+ end
62
+
63
+ Then /^CAS should return me to "([^\"]*)"$/ do |return_to|
64
+ assert_equal return_to, service_url
65
+ end
66
+
67
+ Then /^the "([^\"]*)" header should be "([^\"]*)"$/ do |header, value|
68
+ assert_equal value, response.headers[header]
69
+ end
@@ -0,0 +1,3 @@
1
+ require 'test/unit/assertions'
2
+
3
+ World(Test::Unit::Assertions)
@@ -0,0 +1,89 @@
1
+ require 'rack'
2
+ require 'rack/mock'
3
+ Rack::MockRequest.class_eval do
4
+
5
+ class <<self
6
+ def env_for_with_hook(*args)
7
+ return ::RackSupport.current_env unless ::RackSupport.current_env.nil?
8
+ env = env_for_without_hook(*args)
9
+ ::RackSupport.current_env = env
10
+ env
11
+ end
12
+ alias_method :env_for_without_hook, :env_for
13
+ alias_method :env_for, :env_for_with_hook
14
+ end
15
+
16
+ end
17
+
18
+ module RackSupport
19
+
20
+ VALID_CAS_USER_XML = <<-EOX
21
+ <cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
22
+ <cas:authenticationSuccess>
23
+ <cas:user>%s</cas:user>
24
+ </cas:authenticationSuccess>
25
+ </cas:serviceResponse>
26
+ EOX
27
+
28
+ @@current_env = nil
29
+
30
+ def self.current_env
31
+ @@current_env
32
+ end
33
+
34
+ def self.current_env=(env)
35
+ @@current_env = env
36
+ end
37
+
38
+ attr_accessor :underlying_app, :app, :response, :session
39
+
40
+ def cleanup_rack_variables
41
+ ::RackSupport.current_env = nil
42
+ self.underlying_app = self.app = self.session = self.response = nil
43
+ end
44
+
45
+ def get(url, headers = {})
46
+ env = RackSupport.current_env = Rack::MockRequest.env_for(url, headers)
47
+ if session
48
+ env['rack.session'] = session
49
+ else
50
+ self.session = Rack::Request.new(env).session
51
+ end
52
+ self.response = Rack::MockRequest.new(app).get url, headers
53
+ end
54
+
55
+ def redirected_to
56
+ return nil if response.headers['Location'].nil?
57
+ URI::parse(response.headers['Location'])
58
+ end
59
+
60
+ def service_url
61
+ return nil if redirected_to.nil?
62
+ Rack::Utils.parse_nested_query(redirected_to.query)['service']
63
+ end
64
+
65
+ def http_request_returns_valid_cas_user(username)
66
+ http_request_returns VALID_CAS_USER_XML % username
67
+ end
68
+
69
+ def http_request_returns_error
70
+ http_request_returns "this is not a valid CAS service-ticket-validation response!"
71
+ end
72
+
73
+ def http_request_returns(content)
74
+ server = Object.new
75
+ connection = Object.new
76
+ response = Object.new
77
+ Net::HTTP.stubs(:new).returns(server)
78
+ server.stubs(:start).yields(connection)
79
+ connection.stubs(:get).returns(response)
80
+ response.stubs(:body).returns(content)
81
+ end
82
+
83
+ end
84
+
85
+ After do
86
+ cleanup_rack_variables
87
+ end
88
+
89
+ World(RackSupport)
@@ -0,0 +1,11 @@
1
+ module CasrackTheAuthenticator
2
+
3
+ USERNAME_PARAM = :cas_user
4
+
5
+ autoload :Simple, 'casrack_the_authenticator/simple'
6
+ autoload :Configuration, 'casrack_the_authenticator/configuration'
7
+ autoload :ServiceTicketValidator, 'casrack_the_authenticator/service_ticket_validator'
8
+ autoload :RequireCAS, 'casrack_the_authenticator/require_cas'
9
+ autoload :Fake, 'casrack_the_authenticator/fake'
10
+
11
+ end
@@ -0,0 +1,88 @@
1
+ require 'uri'
2
+ require 'rack'
3
+ require 'rack/utils'
4
+
5
+ module CasrackTheAuthenticator
6
+
7
+ class Configuration
8
+
9
+ DEFAULT_LOGIN_URL = "%s/login"
10
+
11
+ DEFAULT_SERVICE_VALIDATE_URL = "%s/serviceValidate"
12
+
13
+ # @param [Hash] params configuration options
14
+ # @option params [String, nil] :cas_server the CAS server root URL; probably something like
15
+ # 'http://cas.mycompany.com' or 'http://cas.mycompany.com/cas'; optional.
16
+ # @option params [String, nil] :cas_login_url (:cas_server + '/login') the URL to which to
17
+ # redirect for logins; options if <tt>:cas_server</tt> is specified,
18
+ # required otherwise.
19
+ # @option params [String, nil] :cas_service_validate_url (:cas_server + '/serviceValidate') the
20
+ # URL to use for validating service tickets; optional if <tt>:cas_server</tt> is
21
+ # specified, requred otherwise.
22
+ def initialize(params)
23
+ parse_params params
24
+ end
25
+
26
+ # Build a CAS login URL from +service+.
27
+ #
28
+ # @param [String] service the service (a.k.a. return-to) URL
29
+ #
30
+ # @return [String] a URL like
31
+ # "http://cas.mycompany.com/login?service=..."
32
+ def login_url(service)
33
+ append_service @login_url, service
34
+ end
35
+
36
+ # Build a service-validation URL from +service+ and +ticket+.
37
+ #
38
+ # @param [String] service the service (a.k.a. return-to) URL
39
+ # @param [String] ticket the ticket to validate
40
+ #
41
+ # @return [String] a URL like
42
+ # "http://cas.mycompany.com/serviceValidate?service=...&ticket=..."
43
+ def service_validate_url(service, ticket)
44
+ url = append_service @service_validate_url, service
45
+ url << '&ticket=' << Rack::Utils.escape(ticket)
46
+ end
47
+
48
+ private
49
+
50
+ def parse_params(params)
51
+ if params[:cas_server].nil? && params[:cas_login_url].nil?
52
+ raise ArgumentError.new(":cas_server or :cas_login_url MUST be provided")
53
+ end
54
+ @login_url = params[:cas_login_url]
55
+ @login_url ||= DEFAULT_LOGIN_URL % params[:cas_server]
56
+ validate_is_url 'login URL', @login_url
57
+
58
+ if params[:cas_server].nil? && params[:cas_service_validate_url].nil?
59
+ raise ArgumentError.new(":cas_server or :cas_service_validate_url MUST be provided")
60
+ end
61
+ @service_validate_url = params[:cas_service_validate_url]
62
+ @service_validate_url ||= DEFAULT_SERVICE_VALIDATE_URL % params[:cas_server]
63
+ validate_is_url 'service-validate URL', @service_validate_url
64
+ end
65
+
66
+ IS_NOT_URL_ERROR_MESSAGE = "%s is not a valid URL"
67
+
68
+ def validate_is_url(name, possibly_a_url)
69
+ url = URI.parse(possibly_a_url) rescue nil
70
+ raise ArgumentError.new(IS_NOT_URL_ERROR_MESSAGE % name) unless url.kind_of?(URI::HTTP)
71
+ end
72
+
73
+ # Adds +service+ as an URL-escaped parameter to +base+.
74
+ #
75
+ # @param [String] base the base URL
76
+ # @param [String] service the service (a.k.a. return-to) URL.
77
+ #
78
+ # @return [String] the new joined URL.
79
+ def append_service(base, service)
80
+ result = base.dup
81
+ result << (result.include?('?') ? '&' : '?')
82
+ result << 'service='
83
+ result << Rack::Utils.escape(service)
84
+ end
85
+
86
+ end
87
+
88
+ end
@@ -0,0 +1,49 @@
1
+ require 'rack'
2
+ require 'rack/auth/basic'
3
+
4
+ module CasrackTheAuthenticator
5
+
6
+ # A fake CAS authenticator. Good for disconnected-mode
7
+ # development.
8
+ class Fake
9
+
10
+ BASIC_AUTH_401 = [
11
+ 401,
12
+ {
13
+ 'Content-Type' => 'text/plain',
14
+ 'Content-Length' => '0',
15
+ 'WWW-Authenticate' => 'Basic realm="CasrackTheAuthenticator::Fake"'
16
+ },
17
+ []
18
+ ]
19
+
20
+ # @param app the underlying Rack application
21
+ # @param [Array<String>] usernames an Array of usernames that
22
+ # will successfully authenticate as though they
23
+ # had come from CAS.
24
+ def initialize(app, *usernames)
25
+ @app, @usernames = app, usernames
26
+ raise "Why would you create a Fake CAS authenticator but not let anyone use it?" if usernames.empty?
27
+ end
28
+
29
+ def call(env)
30
+ process_basic_auth(env)
31
+ basic_auth_on_401(@app.call(env))
32
+ end
33
+
34
+ private
35
+
36
+ def process_basic_auth(env)
37
+ auth = Rack::Auth::Basic::Request.new(env)
38
+ if auth.provided? && auth.basic? && @usernames.include?(auth.username)
39
+ Rack::Request.new(env).session[CasrackTheAuthenticator::USERNAME_PARAM] = auth.username
40
+ end
41
+ end
42
+
43
+ def basic_auth_on_401(response)
44
+ response[0] == 401 ? BASIC_AUTH_401 : response
45
+ end
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,36 @@
1
+ module CasrackTheAuthenticator
2
+
3
+ class RequireCAS
4
+
5
+ # Create a new RequireCAS middleware, which requires
6
+ # users to log in via CAS for _all_ requests, and
7
+ # returns a 401 Unauthorized if they aren't signed in.
8
+ #
9
+ # @param app the underlying Rack app.
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ if signed_in?(env)
16
+ @app.call(env)
17
+ else
18
+ unauthorized
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ # @return [true, false] whether the user is signed in via CAS.
25
+ def signed_in?(env)
26
+ !Rack::Request.new(env).session[CasrackTheAuthenticator::USERNAME_PARAM].nil?
27
+ end
28
+
29
+ # @return [Array<Integer, Hash, String>] a 401 Unauthorized Rack response.
30
+ def unauthorized
31
+ [401, {}, "CAS Authentication is required"]
32
+ end
33
+
34
+ end
35
+
36
+ end
@@ -0,0 +1,55 @@
1
+ require 'nokogiri'
2
+
3
+ module CasrackTheAuthenticator
4
+
5
+ class ServiceTicketValidator
6
+
7
+ VALIDATION_REQUEST_HEADERS = { 'Accept' => '*/*' }
8
+
9
+ # Build a validator from a +configuration+, a
10
+ # +return_to+ URL, and a +ticket+.
11
+ #
12
+ # @param [CasrackTheAuthenticator::Configuration] configuration the CAS configuration
13
+ # @param [String] return_to_url the URL of this CAS client service
14
+ # @param [String] ticket the service ticket to validate
15
+ def initialize(configuration, return_to_url, ticket)
16
+ @uri = URI.parse(configuration.service_validate_url(return_to_url, ticket))
17
+ end
18
+
19
+ # Request validation of the ticket from the CAS server's
20
+ # serviceValidate (CAS 2.0) function.
21
+ #
22
+ # Swallows all XML parsing errors (and returns +nil+ in those cases).
23
+ #
24
+ # @return [String, nil] a username if the response is valid; +nil+ otherwise.
25
+ #
26
+ # @raise any connection errors encountered.
27
+ def user
28
+ parse_user(get_validation_response_body)
29
+ end
30
+
31
+ private
32
+
33
+ def get_validation_response_body
34
+ result = ''
35
+ Net::HTTP.new(@uri.host, @uri.port).start do |c|
36
+ response = c.get "#{@uri.path}?#{@uri.query}", VALIDATION_REQUEST_HEADERS
37
+ result = response.body
38
+ end
39
+ result
40
+ end
41
+
42
+ def parse_user(body)
43
+ begin
44
+ doc = Nokogiri::XML(body)
45
+ node = doc.xpath('/cas:serviceResponse/cas:authenticationSuccess/cas:user').first
46
+ node ||= doc.xpath('/serviceResponse/authenticationSuccess/user').first # try w/o the namespace just in case
47
+ node.nil? ? nil : node.content
48
+ rescue Nokogiri::XML::XPath::SyntaxError
49
+ nil
50
+ end
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,82 @@
1
+ require 'rack'
2
+ require 'rack/request'
3
+
4
+ module CasrackTheAuthenticator
5
+
6
+ # The most basic CAS client use-case: redirects to CAS
7
+ # if a middleware or endpoint beneath this one returns
8
+ # a 401 response. On successful redirection back from
9
+ # CAS, puts the username of the CAS user in the session
10
+ # under <tt>:cas_user</tt>.
11
+ class Simple
12
+
13
+ # Create a new CAS middleware.
14
+ #
15
+ # @param app the underlying Rack application
16
+ # @param [Hash] options - see
17
+ # CasrackTheAuthenticator::Configuration
18
+ # for more information
19
+ def initialize(app, options)
20
+ @app = app
21
+ @configuration = CasrackTheAuthenticator::Configuration.new(options)
22
+ end
23
+
24
+ # Processes the CAS user if a "ticket" parameter is passed,
25
+ # then calls the underlying Rack application; if the result
26
+ # is a 401, redirects to CAS; otherwise, returns the response
27
+ # unchanged.
28
+ #
29
+ # @return [Array<Integer, Hash, #each>] a Rack response
30
+ def call(env)
31
+ request = Rack::Request.new(env)
32
+ process_return_from_cas(request)
33
+ redirect_on_401(request, @app.call(env))
34
+ end
35
+
36
+ private
37
+
38
+ # ticket processing
39
+
40
+ def process_return_from_cas(request)
41
+ ticket = request.params['ticket']
42
+ if ticket
43
+ validator = ServiceTicketValidator.new(@configuration, service_url(request), ticket)
44
+ request.session[CasrackTheAuthenticator::USERNAME_PARAM] = validator.user
45
+ end
46
+ end
47
+
48
+ # redirection
49
+
50
+ def redirect_on_401(request, response)
51
+ if response[0] == 401
52
+ redirect_to_cas(request)
53
+ else
54
+ response
55
+ end
56
+ end
57
+
58
+ def redirect_to_cas(request)
59
+ service_url = service_url(request)
60
+ [
61
+ 302,
62
+ {
63
+ 'Location' => @configuration.login_url(service_url),
64
+ 'Content-Type' => 'text/plain'
65
+ },
66
+ ["You are being redirected to CAS for sign-in."]
67
+ ]
68
+ end
69
+
70
+ # utils
71
+
72
+ def service_url(request)
73
+ strip_ticket_param request.url
74
+ end
75
+
76
+ def strip_ticket_param(url)
77
+ url.sub(/[\?&]ticket=[^\?&]+/, '')
78
+ end
79
+
80
+ end
81
+
82
+ end
@@ -0,0 +1,83 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class ConfigurationTest < Test::Unit::TestCase
4
+
5
+ def new_config
6
+ CasrackTheAuthenticator::Configuration.new @valid_params
7
+ end
8
+
9
+ context 'a Casrack configuration' do
10
+
11
+ setup do
12
+ @valid_params = {
13
+ :cas_login_url => 'http://cas.example.org/login',
14
+ :cas_service_validate_url => 'http://cas.example.org/service-validate',
15
+ :cas_server => 'http://cas.example.org'
16
+ }
17
+ end
18
+
19
+ should 'be invalid with neither a :cas_server nor a :cas_login_url' do
20
+ assert_raises(ArgumentError) do
21
+ @valid_params[:cas_server] = @valid_params[:cas_login_url] = nil
22
+ new_config
23
+ end
24
+ end
25
+
26
+ should 'be invalid with neither a :cas_server nor a :cas_service_validate_url' do
27
+ assert_raises(ArgumentError) do
28
+ @valid_params[:cas_server] = @valid_params[:cas_service_validate_url] = nil
29
+ new_config
30
+ end
31
+ end
32
+
33
+ should "be invalid with a :cas_login_url that isn't really a URL" do
34
+ assert_raises(ArgumentError) do
35
+ @valid_params[:cas_login_url] = 'not a URL'
36
+ new_config
37
+ end
38
+ end
39
+
40
+ should "be invalid with a :cas_service_validate_url that isn't really a URL" do
41
+ assert_raises(ArgumentError) do
42
+ @valid_params[:cas_service_validate_url] = 'not a URL'
43
+ new_config
44
+ end
45
+ end
46
+
47
+ should 'use the login URL if given' do
48
+ base = @valid_params[:cas_login_url]
49
+ service = 'http://example.org'
50
+ assert_equal base + '?service=http%3A%2F%2Fexample.org', new_config.login_url(service)
51
+ end
52
+
53
+ should 'use the service-validate URL if given' do
54
+ base = @valid_params[:cas_service_validate_url]
55
+ service = 'http://example.org'
56
+ ticket = 'ST-00192'
57
+ svurl = URI.parse new_config.service_validate_url(service, ticket)
58
+ assert svurl.to_s =~ /^#{base}/
59
+ assert_equal service, Rack::Utils.parse_query(svurl.query)['service']
60
+ assert_equal ticket, Rack::Utils.parse_query(svurl.query)['ticket']
61
+ end
62
+
63
+ should 'provide a default login URL if none is given' do
64
+ @valid_params[:cas_login_url] = nil
65
+ base = 'http://cas.example.org/login'
66
+ service = 'http://example.org'
67
+ assert_equal base + '?service=http%3A%2F%2Fexample.org', new_config.login_url(service)
68
+ end
69
+
70
+ should 'provide a default service-validate URL if none is given' do
71
+ @valid_params[:cas_service_validate_url] = nil
72
+ base = 'http://cas.example.org/serviceValidate'
73
+ service = 'http://example.org'
74
+ ticket = 'ST-373737'
75
+ svurl = URI.parse new_config.service_validate_url(service, ticket)
76
+ assert svurl.to_s =~ /^#{base}/
77
+ assert_equal service, Rack::Utils.parse_query(svurl.query)['service']
78
+ assert_equal ticket, Rack::Utils.parse_query(svurl.query)['ticket']
79
+ end
80
+
81
+ end
82
+
83
+ end
data/test/fake_test.rb ADDED
@@ -0,0 +1,94 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class FakeTest < Test::Unit::TestCase
4
+
5
+ def self.should_let_the_response_bubble_up_unaltered
6
+ should 'let the response bubble up unaltered' do
7
+ assert_equal @app_response, @response
8
+ end
9
+ end
10
+
11
+ def self.should_set_the_cas_user_to(user)
12
+ should "set the CAS user to #{user || '<nil>'}" do
13
+ assert_received(@app, :call) do |expects|
14
+ expects.with() do |env|
15
+ assert_equal user, Rack::Request.new(env).session[:cas_user]
16
+ true
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def self.should_prompt_http_basic_authentication
23
+ should 'prompt HTTP basic authentication' do
24
+ assert_equal 401, @response[0]
25
+ assert_equal 'Basic realm="CasrackTheAuthenticator::Fake"', @response[1]['WWW-Authenticate']
26
+ end
27
+ end
28
+
29
+ def auth_headers(user)
30
+ { 'HTTP_AUTHORIZATION' => 'Basic '+ ["#{user}:passw0rd"].pack("m*") }
31
+ end
32
+
33
+ context 'the Fake authenticator' do
34
+
35
+ setup do
36
+ @app = Object.new
37
+ @fake = CasrackTheAuthenticator::Fake.new @app, 'jlevitt'
38
+ end
39
+
40
+ context 'when receiving a non-401 response' do
41
+
42
+ setup do
43
+ @app_response = [ 320, {}, ["Weird redirection"] ]
44
+ @app.stubs(:call).returns @app_response
45
+ @response = @fake.call({})
46
+ end
47
+
48
+ should_let_the_response_bubble_up_unaltered
49
+
50
+ end
51
+
52
+ context 'when receiving a 401 from below' do
53
+
54
+ setup do
55
+ @app_response = [ 401, {}, 'Unauthorized!' ]
56
+ @app.stubs(:call).returns @app_response
57
+ @response = @fake.call({})
58
+ end
59
+
60
+ should_prompt_http_basic_authentication
61
+
62
+ end
63
+
64
+ context 'when getting a valid HTTP Basic user in the request' do
65
+
66
+ setup do
67
+ @app_response = [ 210, {}, ["Success-ish"] ]
68
+ @app.stubs(:call).returns @app_response
69
+ @response = @fake.call(auth_headers('jlevitt'))
70
+ end
71
+
72
+ should_let_the_response_bubble_up_unaltered
73
+
74
+ should_set_the_cas_user_to 'jlevitt'
75
+
76
+ end
77
+
78
+ context 'when getting an invalid HTTP Basic user in the request' do
79
+
80
+ setup do
81
+ @app_response = [ 401, {}, [] ]
82
+ @app.stubs(:call).returns @app_response
83
+ @response = @fake.call(auth_headers('zdeschanel'))
84
+ end
85
+
86
+ should_prompt_http_basic_authentication
87
+
88
+ should_set_the_cas_user_to nil
89
+
90
+ end
91
+
92
+ end
93
+
94
+ end
@@ -0,0 +1,84 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class ServiceTicketValidatorTest < Test::Unit::TestCase
4
+
5
+ context 'a service-ticket validator' do
6
+
7
+ setup do
8
+ config = Object.new
9
+ config.stubs(:service_validate_url).returns('http://cas.example.org/cas/serviceValidate?service=foo&ticket=bar')
10
+ @validator = CasrackTheAuthenticator::ServiceTicketValidator.new(config, nil, nil)
11
+ end
12
+
13
+ context 'validating a ticket' do
14
+
15
+ setup do
16
+ @server = Object.new
17
+ @connection = Object.new
18
+ @response = Object.new
19
+ @body = Object.new
20
+ Net::HTTP.stubs(:new).returns(@server)
21
+ @server.stubs(:start).yields(@connection)
22
+ @connection.stubs(:get).returns(@response)
23
+ @response.stubs(:body).returns(@body)
24
+ end
25
+
26
+ should 'return the body from the service-validate URL' do
27
+ assert_equal @body, @validator.send(:get_validation_response_body)
28
+ assert_received(Net::HTTP, :new) do |expects|
29
+ expects.with('cas.example.org', 80)
30
+ end
31
+ assert_received(@server, :start)
32
+ assert_received(@connection, :get) do |expects|
33
+ expects.with('/cas/serviceValidate?service=foo&ticket=bar', { 'Accept' => '*/*' })
34
+ end
35
+ assert_received(@response, :body)
36
+ end
37
+
38
+ context "but a connection error gets in the way" do
39
+
40
+ setup do
41
+ @server.stubs(:start).raises(SocketError)
42
+ end
43
+
44
+ should 'let the error percolate' do
45
+ assert_raises(SocketError) do
46
+ @validator.send(:get_validation_response_body)
47
+ end
48
+ end
49
+
50
+ end
51
+
52
+ end
53
+
54
+ context 'parsing a successful response' do
55
+
56
+ setup do
57
+ @body = <<-EOX
58
+ <cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
59
+ <cas:authenticationSuccess>
60
+ <cas:user>beatrice</cas:user>
61
+ </cas:authenticationSuccess>
62
+ </cas:serviceResponse>
63
+ EOX
64
+ end
65
+
66
+ should 'get the user' do
67
+ assert_equal 'beatrice', @validator.send(:parse_user, @body)
68
+ end
69
+
70
+ end
71
+
72
+ context 'parsing an unsuccessful response' do
73
+ setup do
74
+ @body = ''
75
+ end
76
+
77
+ should 'return nil' do
78
+ assert_equal nil, @validator.send(:parse_user, @body)
79
+ end
80
+ end
81
+
82
+ end
83
+
84
+ end
@@ -0,0 +1,131 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class SimpleTest < Test::Unit::TestCase
4
+
5
+ context 'creating a Simple authenticator' do
6
+ should 'require a :cas_server' do
7
+ assert_raises(ArgumentError) do
8
+ CasrackTheAuthenticator::Simple.new(:anything, {})
9
+ end
10
+ end
11
+ end
12
+
13
+ def self.should_pass_the_request_on_down
14
+ should "pass the request to the underyling app" do
15
+ assert_received(@app, :call)
16
+ end
17
+ end
18
+
19
+ def self.should_set_the_cas_user_in_the_session_to(username)
20
+ should "set the CAS user in the session to #{username || '<nil>'}" do
21
+ assert_equal username, @session[:cas_user]
22
+ end
23
+ end
24
+
25
+ def get(url)
26
+ env = Rack::MockRequest.env_for(url)
27
+ if @session
28
+ env['rack.session'] = @session
29
+ else
30
+ @session = Rack::Request.new(env).session
31
+ end
32
+ Rack::MockRequest.stubs(:env_for).returns(env)
33
+ @response = @request.get url
34
+ end
35
+
36
+ def param_from_url(param, url)
37
+ uri = URI.parse(url)
38
+ Rack::Utils.parse_nested_query(uri.query)[param]
39
+ end
40
+
41
+ def return_to_url(response)
42
+ param_from_url 'service', response.headers['Location']
43
+ end
44
+
45
+ context 'a Simple authenticator' do
46
+
47
+ setup do
48
+ @app = Object.new
49
+ @authenticator = CasrackTheAuthenticator::Simple.new(@app, {:cas_server => 'http://cas.test/'})
50
+ @request = Rack::MockRequest.new(@authenticator)
51
+ end
52
+
53
+ context 'when receiving a 200 from below' do
54
+
55
+ setup do
56
+ @response_from_below = [ 200, {}, 'Success!' ]
57
+ @app.stubs(:call).returns(@response_from_below)
58
+ get '/'
59
+ end
60
+
61
+ should_pass_the_request_on_down
62
+
63
+ should 'do nothing to the response' do
64
+ assert_equal @response_from_below[0], @response.status
65
+ assert_equal @response_from_below[2], @response.body
66
+ end
67
+
68
+ end
69
+
70
+ context 'when receiving a 401 from below' do
71
+
72
+ setup do
73
+ response = [401, {}, 'Unauthorized!']
74
+ @app.stubs(:call).returns(response)
75
+ @url = "http://foo.bar/baz?yoo=hoo"
76
+ get @url
77
+ end
78
+
79
+ should_pass_the_request_on_down
80
+
81
+ should 'redirect to CAS' do
82
+ assert((300..399).include?(@response.status))
83
+ assert @response.headers['Location'] =~ /cas/i
84
+ end
85
+
86
+ should 'set the content-type to text/plain' do
87
+ assert_equal 'text/plain', @response.headers['Content-Type']
88
+ end
89
+
90
+ should 'use the requested URL for the return-to' do
91
+ assert_equal @url, return_to_url(@response)
92
+ end
93
+
94
+ context "and the request URL includes a 'ticket' param" do
95
+
96
+ setup do
97
+ @url = "http://foo.bar/baz?ticket=12345"
98
+ get @url
99
+ end
100
+
101
+ should 'strip the ticket from the return-to URL' do
102
+ return_to = return_to_url(@response)
103
+ assert_equal nil, param_from_url('ticket', return_to)
104
+ end
105
+
106
+ end
107
+
108
+ end
109
+
110
+ context 'when receiving a valid result from CAS' do
111
+
112
+ setup do
113
+ validator = Object.new
114
+ validator.stubs(:user).returns 'timmy'
115
+ CasrackTheAuthenticator::ServiceTicketValidator.stubs(:new).returns(validator)
116
+ @response_from_below = [ 200, {}, 'Success!' ]
117
+ @app.stubs(:call).returns(@response_from_below)
118
+ get '/?ticket=ST-77889'
119
+ end
120
+
121
+ should_set_the_cas_user_in_the_session_to 'timmy'
122
+
123
+ should 'build a service-ticket validator' do
124
+ assert_received(CasrackTheAuthenticator::ServiceTicketValidator, :new)
125
+ end
126
+
127
+ end
128
+
129
+ end
130
+
131
+ end
@@ -0,0 +1,16 @@
1
+ require 'test/unit'
2
+ require 'test/unit/testcase'
3
+ require 'rubygems'
4
+ require 'shoulda'
5
+ gem 'jferris-mocha'
6
+ require 'mocha'
7
+ require 'redgreen'
8
+
9
+ I_KNOW_I_AM_USING_AN_OLD_AND_BUGGY_VERSION_OF_LIBXML2 = 1
10
+
11
+ lib_path = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
12
+ $: << lib_path unless $:.include?(lib_path)
13
+
14
+ require 'casrack_the_authenticator'
15
+ require 'rack'
16
+ require 'rack/mock'
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: casrack_the_authenticator
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.4.0
5
+ platform: ruby
6
+ authors:
7
+ - James Rosen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-08-05 00:00:00 -04:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: CAS Authentication via Rack Middleware
17
+ email: james.a.rosen@gmail.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - lib/casrack_the_authenticator/configuration.rb
26
+ - lib/casrack_the_authenticator/fake.rb
27
+ - lib/casrack_the_authenticator/require_cas.rb
28
+ - lib/casrack_the_authenticator/service_ticket_validator.rb
29
+ - lib/casrack_the_authenticator/simple.rb
30
+ - lib/casrack_the_authenticator.rb
31
+ - test/configuration_test.rb
32
+ - test/fake_test.rb
33
+ - test/service_ticket_validator_test.rb
34
+ - test/simple_test.rb
35
+ - test/test_helper.rb
36
+ - features/fake.feature
37
+ - features/require_cas.feature
38
+ - features/simple.feature
39
+ - features/step_definitions/fake_cas_steps.rb
40
+ - features/step_definitions/rack_steps.rb
41
+ - features/support/assertions.rb
42
+ - features/support/rack_support.rb
43
+ - README.rdoc
44
+ - Rakefile
45
+ has_rdoc: true
46
+ homepage: http://github.com/gcnovus/casrack_the_authenticator
47
+ licenses: []
48
+
49
+ post_install_message:
50
+ rdoc_options:
51
+ - --line-numbers
52
+ - --inline-source
53
+ - --title
54
+ - "Casrack the Authenticator: RDoc"
55
+ - --charset
56
+ - utf-8
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: "0"
64
+ version:
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: "0"
70
+ version:
71
+ requirements: []
72
+
73
+ rubyforge_project:
74
+ rubygems_version: 1.3.3
75
+ signing_key:
76
+ specification_version: 3
77
+ summary: CAS Authentication via Rack Middleware
78
+ test_files: []
79
+