factorylabs-casrack_the_authenticator 1.6.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/.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
|