tilia-http 4.1.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.
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