factorylabs-casrack_the_authenticator 1.6.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/README.rdoc +87 -0
- data/Rakefile +7 -0
- data/VERSION +1 -0
- data/casrack_the_authenticator.gemspec +83 -0
- data/developer_tasks/doc.rake +22 -0
- data/developer_tasks/gem.rake +20 -0
- data/developer_tasks/test.rake +24 -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/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/lib/casrack_the_authenticator.rb +11 -0
- data/test/configuration_test.rb +83 -0
- data/test/fake_test.rb +94 -0
- data/test/service_ticket_validator_test.rb +85 -0
- data/test/simple_test.rb +131 -0
- data/test/test_helper.rb +16 -0
- metadata +165 -0
@@ -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,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,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
|