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 +87 -0
- data/Rakefile +78 -0
- data/features/fake.feature +34 -0
- data/features/require_cas.feature +19 -0
- data/features/simple.feature +32 -0
- data/features/step_definitions/fake_cas_steps.rb +14 -0
- data/features/step_definitions/rack_steps.rb +69 -0
- data/features/support/assertions.rb +3 -0
- data/features/support/rack_support.rb +89 -0
- data/lib/casrack_the_authenticator.rb +11 -0
- data/lib/casrack_the_authenticator/configuration.rb +88 -0
- data/lib/casrack_the_authenticator/fake.rb +49 -0
- data/lib/casrack_the_authenticator/require_cas.rb +36 -0
- data/lib/casrack_the_authenticator/service_ticket_validator.rb +55 -0
- data/lib/casrack_the_authenticator/simple.rb +82 -0
- data/test/configuration_test.rb +83 -0
- data/test/fake_test.rb +94 -0
- data/test/service_ticket_validator_test.rb +84 -0
- data/test/simple_test.rb +131 -0
- data/test/test_helper.rb +16 -0
- metadata +79 -0
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,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
|
data/test/simple_test.rb
ADDED
@@ -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
|
data/test/test_helper.rb
ADDED
@@ -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
|
+
|