tilia-http 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +35 -0
  5. data/.simplecov +4 -0
  6. data/.travis.yml +3 -0
  7. data/CHANGELOG.sabre.md +235 -0
  8. data/CONTRIBUTING.md +25 -0
  9. data/Gemfile +18 -0
  10. data/Gemfile.lock +69 -0
  11. data/LICENSE +27 -0
  12. data/LICENSE.sabre +27 -0
  13. data/README.md +68 -0
  14. data/Rakefile +17 -0
  15. data/examples/asyncclient.rb +45 -0
  16. data/examples/basicauth.rb +39 -0
  17. data/examples/client.rb +20 -0
  18. data/examples/reverseproxy.rb +39 -0
  19. data/examples/stringify.rb +37 -0
  20. data/lib/tilia/http/auth/abstract_auth.rb +51 -0
  21. data/lib/tilia/http/auth/aws.rb +191 -0
  22. data/lib/tilia/http/auth/basic.rb +43 -0
  23. data/lib/tilia/http/auth/bearer.rb +37 -0
  24. data/lib/tilia/http/auth/digest.rb +187 -0
  25. data/lib/tilia/http/auth.rb +12 -0
  26. data/lib/tilia/http/client.rb +452 -0
  27. data/lib/tilia/http/client_exception.rb +15 -0
  28. data/lib/tilia/http/client_http_exception.rb +37 -0
  29. data/lib/tilia/http/http_exception.rb +21 -0
  30. data/lib/tilia/http/message.rb +241 -0
  31. data/lib/tilia/http/message_decorator_trait.rb +183 -0
  32. data/lib/tilia/http/message_interface.rb +154 -0
  33. data/lib/tilia/http/request.rb +235 -0
  34. data/lib/tilia/http/request_decorator.rb +160 -0
  35. data/lib/tilia/http/request_interface.rb +126 -0
  36. data/lib/tilia/http/response.rb +164 -0
  37. data/lib/tilia/http/response_decorator.rb +58 -0
  38. data/lib/tilia/http/response_interface.rb +36 -0
  39. data/lib/tilia/http/sapi.rb +165 -0
  40. data/lib/tilia/http/url_util.rb +70 -0
  41. data/lib/tilia/http/util.rb +51 -0
  42. data/lib/tilia/http/version.rb +9 -0
  43. data/lib/tilia/http.rb +416 -0
  44. data/test/http/auth/aws_test.rb +189 -0
  45. data/test/http/auth/basic_test.rb +60 -0
  46. data/test/http/auth/bearer_test.rb +47 -0
  47. data/test/http/auth/digest_test.rb +141 -0
  48. data/test/http/client_mock.rb +101 -0
  49. data/test/http/client_test.rb +331 -0
  50. data/test/http/message_decorator_test.rb +67 -0
  51. data/test/http/message_test.rb +163 -0
  52. data/test/http/request_decorator_test.rb +87 -0
  53. data/test/http/request_test.rb +132 -0
  54. data/test/http/response_decorator_test.rb +28 -0
  55. data/test/http/response_test.rb +38 -0
  56. data/test/http/sapi_mock.rb +12 -0
  57. data/test/http/sapi_test.rb +133 -0
  58. data/test/http/url_util_test.rb +155 -0
  59. data/test/http/util_test.rb +186 -0
  60. data/test/http_test.rb +102 -0
  61. data/test/test_helper.rb +6 -0
  62. data/tilia-http.gemspec +18 -0
  63. metadata +192 -0
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # The url we're proxying to.
3
+ remote_url = 'http://example.org/'
4
+
5
+ # The url we're proxying from. Please note that this must be a relative url,
6
+ # and basically acts as the base url.
7
+ #
8
+ # If your remote_url doesn't end with a slash, this one probably shouldn't
9
+ # either.
10
+ my_base_url = ''
11
+ # my_base_url = '/~evert/sabre/http/examples/reverseproxy.php/'
12
+
13
+ # Expected to be called "bundle exec examples/reverseproxy.rb"
14
+ require './lib/tilia_http'
15
+ require 'rack'
16
+
17
+ app = proc do |env|
18
+ sapi = Tilia::Http::Sapi.new(env)
19
+ request = sapi.request
20
+ request.base_url = my_base_url
21
+
22
+ sub_request = request.dup
23
+
24
+ # Removing the Host header.
25
+ sub_request.remove_header('Host')
26
+
27
+ # Rewriting the url.
28
+ sub_request.url = remote_url + request.path
29
+
30
+ client = Tilia::Http::Client.new
31
+
32
+ # Sends the HTTP request to the server
33
+ response = client.send(sub_request)
34
+
35
+ # Sends the response back to the client that connected to the proxy.
36
+ sapi.send_response(response)
37
+ end
38
+
39
+ Rack::Handler::WEBrick.run app
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ require 'json'
3
+ # This simple example shows the capability of Request and Response objects to
4
+ # serialize themselves as strings.
5
+ #
6
+ # This is mainly useful for debugging purposes.
7
+ #
8
+ # @copyright Copyright (C) 2009-2015 fruux GmbH (https://fruux.com/).
9
+ # @author Evert Pot (http://evertpot.com/)
10
+ # @license http://sabre.io/license/ Modified BSD License
11
+
12
+ # Expected to be called "bundle exec examples/stringify.rb"
13
+ require './lib/tilia_http'
14
+
15
+ request = Tilia::Http::Request.new('POST', '/foo')
16
+ request.update_headers(
17
+ 'Host' => 'example.org',
18
+ 'Content-Type' => 'application/json'
19
+ )
20
+
21
+ request.body = JSON.generate('foo' => 'bar')
22
+
23
+ puts request.to_s
24
+ puts
25
+ puts
26
+
27
+ response = Tilia::Http::Response.new(424)
28
+ response.update_headers(
29
+ 'Content-Type' => 'text/plain',
30
+ 'Connection' => 'close'
31
+ )
32
+
33
+ response.body = 'ABORT! ABORT!'
34
+
35
+ puts response.to_s
36
+
37
+ puts
@@ -0,0 +1,51 @@
1
+ module Tilia
2
+ module Http
3
+ module Auth
4
+ # HTTP Authentication base class.
5
+ #
6
+ # This class provides some common functionality for the various base classes.
7
+ class AbstractAuth
8
+ protected
9
+
10
+ # Authentication realm
11
+ #
12
+ # @return [String]
13
+ attr_accessor :realm
14
+
15
+ # Request object
16
+ #
17
+ # @return [RequestInterface]
18
+ attr_accessor :request
19
+
20
+ # Response object
21
+ #
22
+ # @return [ResponseInterface]
23
+ attr_accessor :response
24
+
25
+ public
26
+
27
+ # Creates the object
28
+ #
29
+ # @param [String] realm
30
+ # @return [void]
31
+ def initialize(realm = 'TiliaTooth', request, response)
32
+ @realm = realm
33
+ @request = request
34
+ @response = response
35
+ end
36
+
37
+ # This method sends the needed HTTP header and statuscode (401) to force
38
+ # the user to login.
39
+ #
40
+ # @return [void]
41
+ def require_login
42
+ end
43
+
44
+ # Returns the HTTP realm
45
+ #
46
+ # @return [String]
47
+ attr_reader :realm
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,191 @@
1
+ require 'digest'
2
+ require 'base64'
3
+ require 'openssl'
4
+ module Tilia
5
+ module Http
6
+ module Auth
7
+ # HTTP AWS Authentication handler
8
+ #
9
+ # Use this class to leverage amazon's AWS authentication header
10
+ class Aws < Tilia::Http::Auth::AbstractAuth
11
+ protected
12
+
13
+ # The signature supplied by the HTTP client
14
+ #
15
+ # @return [String]
16
+ attr_accessor :signature
17
+
18
+ # The accesskey supplied by the HTTP client
19
+ #
20
+ # @return [String]
21
+ attr_accessor :access_key
22
+
23
+ public
24
+
25
+ # An error code, if any
26
+ #
27
+ # This value will be filled with one of the ERR_* constants
28
+ #
29
+ # @return int
30
+ attr_accessor :error_code
31
+
32
+ ERR_NOAWSHEADER = 1
33
+ ERR_MD5CHECKSUMWRONG = 2
34
+ ERR_INVALIDDATEFORMAT = 3
35
+ ERR_REQUESTTIMESKEWED = 4
36
+ ERR_INVALIDSIGNATURE = 5
37
+
38
+ # Gathers all information from the headers
39
+ #
40
+ # This method needs to be called prior to anything else.
41
+ #
42
+ # @return bool
43
+ def init
44
+ auth_header = @request.header('Authorization') || ''
45
+ auth_header = auth_header.split(' ')
46
+
47
+ if auth_header[0] != 'AWS' || auth_header.size < 2
48
+ @error_code = self.class::ERR_NOAWSHEADER
49
+ return false
50
+ end
51
+
52
+ (@access_key, @signature) = auth_header[1].split(':')
53
+
54
+ true
55
+ end
56
+
57
+ # Returns the username for the request
58
+ #
59
+ # @return [String]
60
+ attr_reader :access_key
61
+
62
+ # Validates the signature based on the secretKey
63
+ #
64
+ # @param [String] secret_key
65
+ # @return bool
66
+ def validate(secret_key)
67
+ content_md5 = @request.header('Content-MD5')
68
+
69
+ if content_md5
70
+ # We need to validate the integrity of the request
71
+ body = @request.body
72
+ @request.body = body
73
+
74
+ if content_md5 != Base64.strict_encode64(::Digest::MD5.digest(body.to_s))
75
+ # content-md5 header did not match md5 signature of body
76
+ @error_code = self.class::ERR_MD5CHECKSUMWRONG
77
+ return false
78
+ end
79
+ end
80
+
81
+ request_date = @request.header('x-amz-date')
82
+ request_date = @request.header('Date') unless request_date
83
+
84
+ return false unless validate_rfc2616_date(request_date)
85
+
86
+ amz_headers = self.amz_headers
87
+
88
+ signature = Base64.strict_encode64(
89
+ hmacsha1(
90
+ secret_key,
91
+ @request.method + "\n" +
92
+ content_md5 + "\n" +
93
+ @request.header('Content-type').to_s + "\n" +
94
+ request_date + "\n" +
95
+ amz_headers +
96
+ @request.url
97
+ )
98
+ )
99
+
100
+ unless @signature == signature
101
+ @error_code = self.class::ERR_INVALIDSIGNATURE
102
+ return false
103
+ end
104
+ true
105
+ end
106
+
107
+ # Returns an HTTP 401 header, forcing login
108
+ #
109
+ # This should be called when username and password are incorrect, or not supplied at all
110
+ #
111
+ # @return [void]
112
+ def require_login
113
+ @response.add_header('WWW-Authenticate', 'AWS')
114
+ @response.status = 401
115
+ end
116
+
117
+ protected
118
+
119
+ # Makes sure the supplied value is a valid RFC2616 date.
120
+ #
121
+ # If we would just use strtotime to get a valid timestamp, we have no way of checking if a
122
+ # user just supplied the word 'now' for the date header.
123
+ #
124
+ # This function also makes sure the Date header is within 15 minutes of the operating
125
+ # system date, to prevent replay attacks.
126
+ #
127
+ # @param [String] date_header
128
+ # @return bool
129
+ def validate_rfc2616_date(date_header)
130
+ date = Tilia::Http::Util.parse_http_date(date_header)
131
+
132
+ # Unknown format
133
+ unless date
134
+ @error_code = self.class::ERR_INVALIDDATEFORMAT
135
+ return false
136
+ end
137
+
138
+ min = Time.zone.now - 15.minutes
139
+ max = Time.zone.now + 15.minutes
140
+
141
+ # We allow 15 minutes around the current date/time
142
+ if date > max || date < min
143
+ @error_code = self.class::ERR_REQUESTTIMESKEWED
144
+ return false
145
+ end
146
+
147
+ date
148
+ end
149
+
150
+ # Returns a list of AMZ headers
151
+ #
152
+ # @return [String]
153
+ def amz_headers
154
+ amz_headers = {}
155
+ headers = @request.headers
156
+
157
+ headers.each do |header_name, header_value|
158
+ if header_name.downcase.index('x-amz-') == 0
159
+ amz_headers[header_name.downcase] = header_value[0].gsub(/\r?\n/, ' ') + "\n"
160
+ end
161
+ end
162
+
163
+ header_str = ''
164
+ amz_headers.keys.sort.each do |h|
165
+ header_str << "#{h}:#{amz_headers[h]}"
166
+ end
167
+
168
+ header_str
169
+ end
170
+
171
+ private
172
+
173
+ # Generates an HMAC-SHA1 signature
174
+ #
175
+ # @param [String] key
176
+ # @param [String] message
177
+ # @return [String]
178
+ def hmacsha1(key, message)
179
+ # Built in in Ruby
180
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), key, message)
181
+ end
182
+
183
+ # TODO: document
184
+ def initialize(realm = 'TiliaTooth', request, response)
185
+ super
186
+ @error_code = 0
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,43 @@
1
+ require 'base64'
2
+ module Tilia
3
+ module Http
4
+ module Auth
5
+ # HTTP Basic authentication utility.
6
+ #
7
+ # This class helps you setup basic auth. The process is fairly simple:
8
+ #
9
+ # 1. Instantiate the class.
10
+ # 2. Call getCredentials (this will return null or a user/pass pair)
11
+ # 3. If you didn't get valid credentials, call 'requireLogin'
12
+ class Basic < Tilia::Http::Auth::AbstractAuth
13
+ # This method returns a numeric array with a username and password as the
14
+ # only elements.
15
+ #
16
+ # If no credentials were found, this method returns null.
17
+ #
18
+ # @return null|array
19
+ def credentials
20
+ auth = @request.header('Authorization')
21
+
22
+ return nil unless auth
23
+ return nil unless auth[0..5].downcase == 'basic '
24
+
25
+ credentials = Base64.decode64(auth[6..-1]).split(':', 2)
26
+
27
+ return nil unless credentials.size == 2
28
+
29
+ credentials
30
+ end
31
+
32
+ # This method sends the needed HTTP header and statuscode (401) to force
33
+ # the user to login.
34
+ #
35
+ # @return [void]
36
+ def require_login
37
+ @response.add_header('WWW-Authenticate', "Basic realm=\"#{@realm}\"")
38
+ @response.status = 401
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ module Tilia
2
+ module Http
3
+ module Auth
4
+ # HTTP Bearer authentication utility.
5
+ #
6
+ # This class helps you setup bearer auth. The process is fairly simple:
7
+ #
8
+ # 1. Instantiate the class.
9
+ # 2. Call getToken (this will return null or a token as string)
10
+ # 3. If you didn't get a valid token, call 'requireLogin'
11
+ class Bearer < Tilia::Http::Auth::AbstractAuth
12
+ # This method returns a string with an access token.
13
+ #
14
+ # If no token was found, this method returns null.
15
+ #
16
+ # @return null|string
17
+ def token
18
+ auth = @request.header('Authorization')
19
+
20
+ return nil unless auth
21
+ return nil unless auth[0..6].downcase == 'bearer '
22
+
23
+ auth[7..-1]
24
+ end
25
+
26
+ # This method sends the needed HTTP header and statuscode (401) to force
27
+ # authentication.
28
+ #
29
+ # @return [void]
30
+ def require_login
31
+ @response.add_header('WWW-Authenticate', "Bearer realm=\"#{@realm}\"")
32
+ @response.status = 401
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,187 @@
1
+ require 'digest'
2
+ module Tilia
3
+ module Http
4
+ module Auth
5
+ # HTTP Digest Authentication handler
6
+ #
7
+ # Use this class for easy http digest authentication.
8
+ # Instructions:
9
+ #
10
+ # 1. Create the object
11
+ # 2. Call the set_realm method with the realm you plan to use
12
+ # 3. Call the init method function.
13
+ # 4. Call the user_name function. This function may return null if no
14
+ # authentication information was supplied. Based on the username you
15
+ # should check your internal database for either the associated password,
16
+ # or the so-called A1 hash of the digest.
17
+ # 5. Call either validate_password or validate_a1. This will return true
18
+ # or false.
19
+ # 6. To make sure an authentication prompt is displayed, call the
20
+ # require_login method.
21
+ class Digest < Tilia::Http::Auth::AbstractAuth
22
+ # These constants are used in qop
23
+ QOP_AUTH = 1
24
+ QOP_AUTHINT = 2
25
+
26
+ protected
27
+
28
+ attr_accessor :nonce
29
+ attr_accessor :opaque
30
+ attr_accessor :digest_parts
31
+ attr_accessor :a1
32
+ attr_reader :qop
33
+
34
+ public
35
+
36
+ # Initializes the object
37
+ def initialize(realm = 'TiliaTooth', request, response)
38
+ @qop = self.class::QOP_AUTH
39
+
40
+ @nonce = ::Digest::SHA1.hexdigest((Time.now.to_f + rand).to_s)[0..14]
41
+ @opaque = ::Digest::MD5.hexdigest(realm)
42
+ super(realm, request, response)
43
+ end
44
+
45
+ # Gathers all information from the headers
46
+ #
47
+ # This method needs to be called prior to anything else.
48
+ #
49
+ # @return [void]
50
+ def init
51
+ digest = self.digest
52
+ @digest_parts = parse_digest(digest)
53
+ end
54
+
55
+ # Sets the quality of protection value.
56
+ #
57
+ # Possible values are:
58
+ # Sabre\HTTP\DigestAuth::QOP_AUTH
59
+ # Sabre\HTTP\DigestAuth::QOP_AUTHINT
60
+ #
61
+ # Multiple values can be specified using logical OR.
62
+ #
63
+ # QOP_AUTHINT ensures integrity of the request body, but this is not
64
+ # supported by most HTTP clients. QOP_AUTHINT also requires the entire
65
+ # request body to be md5'ed, which can put strains on CPU and memory.
66
+ #
67
+ # @param int qop
68
+ # @return [void]
69
+ attr_writer :qop
70
+
71
+ # Validates the user.
72
+ #
73
+ # The A1 parameter should be md5(username . ':' . realm . ':' . password)
74
+ #
75
+ # @param [String] a1
76
+ # @return bool
77
+ def validate_a1(a1)
78
+ @a1 = a1
79
+ validate
80
+ end
81
+
82
+ # Validates authentication through a password. The actual password must be provided here.
83
+ # It is strongly recommended not store the password in plain-text and use validateA1 instead.
84
+ #
85
+ # @param [String] password
86
+ # @return bool
87
+ def validate_password(password)
88
+ return false unless @digest_parts.any? # RUBY
89
+
90
+ @a1 = ::Digest::MD5.hexdigest(@digest_parts['username'] + ':' + @realm + ':' + password)
91
+ validate
92
+ end
93
+
94
+ # Returns the username for the request
95
+ #
96
+ # @return [String]
97
+ def username
98
+ @digest_parts['username']
99
+ end
100
+
101
+ protected
102
+
103
+ # Validates the digest challenge
104
+ #
105
+ # @return bool
106
+ def validate
107
+ return false unless @digest_parts.any? # RUBY
108
+
109
+ a2 = @request.method + ':' + @digest_parts['uri']
110
+
111
+ if @digest_parts['qop'] == 'auth-int'
112
+ # Making sure we support this qop value
113
+ return false unless @qop & self.class::QOP_AUTHINT
114
+
115
+ # We need to add an md5 of the entire request body to the A2 part of the hash
116
+ body = @request.body_as_string
117
+ @request.body = body
118
+
119
+ a2 << ':' + ::Digest::MD5.hexdigest(body)
120
+ else
121
+ # We need to make sure we support this qop value
122
+ return false unless @qop & self.class::QOP_AUTH
123
+ end
124
+
125
+ a2 = ::Digest::MD5.hexdigest(a2)
126
+ valid_response = ::Digest::MD5.hexdigest("#{@a1}:#{@digest_parts['nonce']}:#{@digest_parts['nc']}:#{@digest_parts['cnonce']}:#{@digest_parts['qop']}:#{a2}")
127
+
128
+ @digest_parts['response'] == valid_response
129
+ end
130
+
131
+ public
132
+
133
+ # Returns an HTTP 401 header, forcing login
134
+ #
135
+ # This should be called when username and password are incorrect, or not supplied at all
136
+ #
137
+ # @return [void]
138
+ def require_login
139
+ qop = ''
140
+ case @qop
141
+ when self.class::QOP_AUTH
142
+ qop = 'auth'
143
+ when self.class::QOP_AUTHINT
144
+ qop = 'auth-int'
145
+ when self.class::QOP_AUTH | self.class::QOP_AUTHINT
146
+ qop = 'auth,auth-int'
147
+ end
148
+
149
+ @response.add_header('WWW-Authenticate', "Digest realm=\"#{@realm}\",qop=\"#{qop}\",nonce=\"#{@nonce}\",opaque=\"#{@opaque}\"")
150
+ @response.status = 401
151
+ end
152
+
153
+ # This method returns the full digest string.
154
+ #
155
+ # It should be compatibile with mod_php format and other webservers.
156
+ #
157
+ # If the header could not be found, null will be returned
158
+ #
159
+ # @return mixed
160
+ def digest
161
+ @request.header('Authorization')
162
+ end
163
+
164
+ protected
165
+
166
+ # Parses the different pieces of the digest string into an array.
167
+ #
168
+ # This method returns false if an incomplete digest was supplied
169
+ #
170
+ # @param [String] digest
171
+ # @return mixed
172
+ def parse_digest(digest)
173
+ # protect against missing data
174
+ needed_parts = { 'nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1 }
175
+ data = {}
176
+
177
+ digest.scan(/(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))/) do |m1, m2, m3|
178
+ data[m1] = m2 ? m2 : m3
179
+ needed_parts.delete m1
180
+ end
181
+
182
+ needed_parts.any? ? {} : data
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,12 @@
1
+ module Tilia
2
+ module Http
3
+ # Namespace for Tilia::Http::Auth::* classes
4
+ module Auth
5
+ require 'tilia/http/auth/abstract_auth'
6
+ require 'tilia/http/auth/aws'
7
+ require 'tilia/http/auth/basic'
8
+ require 'tilia/http/auth/bearer'
9
+ require 'tilia/http/auth/digest'
10
+ end
11
+ end
12
+ end