factorylabs-casrack_the_authenticator 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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