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.
@@ -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