casrack_the_authenticator 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
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
+