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 +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
|
+
|