uri_signer 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,123 @@
1
+ module UriSigner
2
+ # This is responsible for preparing the raw request in the format necessary for signing. API URI signatures come in a few different flavors. This one
3
+ # will take HTTP_METHOD & ESCAPED_BASE_URI & SORTED_AND_ESCAPED_QUERY_PARAMS
4
+ #
5
+ # @example
6
+ #
7
+ # request_signature = UriSigner::RequestSignature.new('get', 'https://api.example.com/core/people.json', { 'page' => 5, 'per_page' => 25 })
8
+ #
9
+ # request_signature.http_method
10
+ # # => 'GET'
11
+ #
12
+ # request_signature.base_uri
13
+ # # => 'https://api.example.com/core/people.json'
14
+ #
15
+ # request_signature.query_params
16
+ # # => { 'page' => 5, 'per_page' => 25' }
17
+ #
18
+ # request_signature.query_params?
19
+ # # => true
20
+ #
21
+ # request_signature.encoded_base_uri
22
+ # # => "https%3A%2F%2Fapi.example.com%2Fcore%2Fpeople.json"
23
+ #
24
+ # request_signature.encoded_query_params
25
+ # # => "page%3D5%26per_page%3D25"
26
+ #
27
+ # request_signature.signature
28
+ # # => "GET&https%3A%2F%2Fapi.example.com%2Fcore%2Fpeople.json&page%3D5%26per_page%3D25"
29
+ #
30
+ class RequestSignature
31
+
32
+ # The default separator used to join the http_method, encoded_base_uri, and encoded_query_params
33
+ attr_reader :separator
34
+
35
+ # Create a new RequestSignature instance
36
+ #
37
+ # @param http_method [String] The HTTP method from the request (GET, POST, PUT, or DELETE)
38
+ # @param base_uri [String] The base URI of the request. This is everything except the query string params
39
+ # @param query_params [Hash] A hash of the provided query string params
40
+ #
41
+ # It's required that you provide at least the http_method and base_uri. Params are optional
42
+ #
43
+ # @return [void]
44
+ def initialize(http_method, base_uri, query_params = {})
45
+ @http_method = http_method
46
+ @base_uri = base_uri
47
+ @query_params = query_params
48
+ @separator = '&'
49
+
50
+ raise UriSigner::Errors::MissingHttpMethodError.new("Please provide an HTTP method") unless http_method?
51
+ raise UriSigner::Errors::MissingBaseUriError.new("Please provide a Base URI") unless base_uri?
52
+ end
53
+
54
+ # Returns the full signature string
55
+ #
56
+ # @return [String]
57
+ def signature
58
+ core_signature = [self.http_method, self.encoded_base_uri]
59
+ core_signature << self.encoded_query_params if self.query_params?
60
+ core_signature.join(self.separator)
61
+ end
62
+ alias :to_s :signature
63
+
64
+ # Returns the uppercased HTTP Method
65
+ #
66
+ # @return [String]
67
+ def http_method
68
+ @http_method.upcase
69
+ end
70
+
71
+ # Returns the base URI
72
+ #
73
+ # @return [String]
74
+ def base_uri
75
+ @base_uri
76
+ end
77
+
78
+ # Returns the encoded base_uri
79
+ #
80
+ # This can be used for comparison to ensure the escaping is what you want
81
+ #
82
+ # @return [String] Escaped string of the base_uri
83
+ def encoded_base_uri
84
+ self.base_uri.extend(UriSigner::Helpers::String).escaped
85
+ end
86
+
87
+ # Returns the Query String parameters
88
+ #
89
+ # @return [Hash] The keys are stringified
90
+ def query_params
91
+ @query_params.extend(UriSigner::Helpers::Hash).stringify_keys
92
+ end
93
+
94
+ # Returns true if query params were provided
95
+ #
96
+ # @return [Bool]
97
+ def query_params?
98
+ !@query_params.blank?
99
+ end
100
+
101
+ # Returns the encoded query params as a string
102
+ #
103
+ # This joins the keys and values in one string, then joins them. Then it will escape the final contents.
104
+ #
105
+ # @return [String] Escaped string of the query params
106
+ def encoded_query_params
107
+ query_params_string.extend(UriSigner::Helpers::String).escaped
108
+ end
109
+
110
+ private
111
+ def http_method?
112
+ !@http_method.blank?
113
+ end
114
+
115
+ def base_uri?
116
+ !@base_uri.blank?
117
+ end
118
+
119
+ def query_params_string
120
+ @query_params_string ||= UriSigner::QueryHashParser.new(self.query_params).to_s
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,108 @@
1
+ module UriSigner
2
+ # This is the object that wraps the other building blocks and can be used to sign requests.
3
+ #
4
+ # @example
5
+ # http_method = "get"
6
+ # uri = "https://api.example.com/core/people.json?page=5&per_page=25&order=name:desc&select=id,name"
7
+ # secret = "my_secret"
8
+ #
9
+ # signer = UriSigner::UriSigner.new(http_method, uri, secret)
10
+ #
11
+ # signer.http_method
12
+ # # => "GET"
13
+ #
14
+ # signer.uri
15
+ # # => "https://api.example.com/core/people.json?page=5&per_page=25&order=name:desc&select=id,name"
16
+ #
17
+ # signer.signature
18
+ # # => "1AaJvChjz%2BZYJKxWsUQWNK1a%2BeGjpCs6uwQKwPw1%2FV8%3D"
19
+ #
20
+ # signer.uri_with_signature
21
+ # # => "https://api.example.com/core/people.json?_signature=6G4xiABih7FGvjwB1JsYXoeETtBCOdshIu93X1hltzk%3D"
22
+ #
23
+ # signer.valid?("1AaJvChjz%2BZYJKxWsUQWNK1a%2BeGjpCs6uwQKwPw1%2FV8%3D")
24
+ # # => true
25
+ #
26
+ # signer.valid?('1234')
27
+ # # => false
28
+ #
29
+ class Signer
30
+ # Create a new UriSigner instance
31
+ #
32
+ # @param http_method [String] The HTTP method used to make the request (GET, POST, PUT, and DELETE)
33
+ # @param uri [String] The requested URI
34
+ # @param secret [String] The secret that is used to sign the request
35
+ #
36
+ # @return [void]
37
+ def initialize(http_method, uri, secret)
38
+ @http_method = http_method
39
+ @uri = uri
40
+ @secret = secret
41
+
42
+ raise UriSigner::Errors::MissingHttpMethodError.new("Please provide an HTTP method") unless http_method?
43
+ raise UriSigner::Errors::MissingUriError.new("Please provide a URI") unless uri?
44
+ raise UriSigner::Errors::MissingSecretError.new("Please provide a secret to sign the string") unless secret?
45
+ end
46
+
47
+ # Returns the URI passed into the constructor
48
+ #
49
+ # @return [String]
50
+ def uri
51
+ @uri
52
+ end
53
+
54
+ # Returns the URI with the signature appended to the query string
55
+ #
56
+ # return [String]
57
+ def uri_with_signature
58
+ separator = if request_parser.query_params? then '&' else '?' end
59
+ "%s%s_signature=%s" % [self.uri, separator, self.signature]
60
+ end
61
+
62
+ # Returns the uppercased HTTP Method
63
+ #
64
+ # @return [String]
65
+ def http_method
66
+ @http_method.to_s.upcase
67
+ end
68
+
69
+ # Returns the signature
70
+ #
71
+ # @return [String]
72
+ def signature
73
+ uri_signature.signature
74
+ end
75
+
76
+ # Returns true if +other+ matches the proper signature
77
+ #
78
+ # @return [Bool]
79
+ def valid?(other)
80
+ self.signature === other
81
+ end
82
+
83
+ private
84
+ def uri?
85
+ !@uri.blank?
86
+ end
87
+
88
+ def http_method?
89
+ !@http_method.blank?
90
+ end
91
+
92
+ def secret?
93
+ !@secret.blank?
94
+ end
95
+
96
+ def request_parser
97
+ @request_parser ||= UriSigner::RequestParser.new(self.http_method, self.uri)
98
+ end
99
+
100
+ def request_signature
101
+ @request_signature ||= UriSigner::RequestSignature.new(request_parser.http_method, request_parser.base_uri, request_parser.query_params)
102
+ end
103
+
104
+ def uri_signature
105
+ @uri_signature ||= UriSigner::UriSignature.new(request_signature.signature, @secret)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,79 @@
1
+ module UriSigner
2
+ # This is the object that will be used to verify properly signed API URI requests
3
+ # The #secret is stored in the persistence layer for comparison. There is an API Key
4
+ # and a shared secret. All requests will be signed with the shared secret. The URI
5
+ # will also include a _signature param, where the client will sign the request and
6
+ # store it in the URI.
7
+ #
8
+ # The signing algorithm looks like this:
9
+ #
10
+ # @example
11
+ # secret = "my_secret"
12
+ # string_to_sign = "http://api.example.com/url/to_sign.json"
13
+ #
14
+ # hmac = HMAC::SHA256.new(secret)
15
+ #
16
+ # hmac.digest
17
+ # # => "??B\230????șo\271$'\256A?d?\223L\244\225\231\exR\270U"
18
+ #
19
+ # hmac << string_to_sign
20
+ #
21
+ # hmac.digest
22
+ # # => "?m?j\2761\031\235\206\260?A?\f\263\216\221\fBH?fC\215Ļ\204\233\202@/e"
23
+ #
24
+ # encoded = Base64.encode64(hmac.digest).chomp
25
+ # # => "8W3xar4xGZ2GsOJBmAyzjpEMQkg/ZkONxLuEm4JAL2U="
26
+ #
27
+ # escaped = Rack::Utils.escape(encoded)
28
+ # # => "8W3xar4xGZ2GsOJBmAyzjpEMQkg%2FZkONxLuEm4JAL2U%3D"
29
+ #
30
+ # # The final signed string is "8W3xar4xGZ2GsOJBmAyzjpEMQkg%2FZkONxLuEm4JAL2U%3D"
31
+ #
32
+ class UriSignature
33
+ # Create a new UriSignature instance
34
+ #
35
+ # @param signature_string [String] the string that needs to be signed
36
+ # @param secret [String] the secret to use for the signature
37
+ #
38
+ # @return [void]
39
+ def initialize(signature_string, secret)
40
+ @signature_string = signature_string
41
+ @secret = secret
42
+
43
+ raise UriSigner::Errors::MissingSignatureStringError.new("Please provide a string to sign") unless signature_string?
44
+ raise UriSigner::Errors::MissingSecretError.new("Please provide a secret to sign the string") unless secret?
45
+ end
46
+
47
+ # Return the signature string that was provided in the constructor
48
+ #
49
+ # @return [String]
50
+ def signature_string
51
+ @signature_string
52
+ end
53
+
54
+ # Return the signature_string after being signed with the secret
55
+ #
56
+ # @return [String]
57
+ def signature
58
+ @signature ||= sign!
59
+ end
60
+ alias :to_s :signature
61
+
62
+ private
63
+ def signature_string?
64
+ !@signature_string.blank?
65
+ end
66
+
67
+ def secret?
68
+ !@secret.blank?
69
+ end
70
+
71
+ def sign!
72
+ extension = UriSigner::Helpers::String
73
+
74
+ hmac = self.signature_string.extend(extension).hmac_signed_with(@secret)
75
+ encoded = hmac.extend(extension).base64_encoded
76
+ escaped = encoded.extend(extension).escaped
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ module UriSigner
2
+ VERSION = "0.0.1"
3
+ end
data/reload_yard ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+
3
+ yardoc "lib/uri_signer/**/*.rb" && yard server
@@ -0,0 +1,79 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '../lib/uri_signer'))
2
+
3
+ class CustomHash < Hash; end
4
+
5
+ describe UriSigner::QueryHashParser do
6
+ before do
7
+ @query_hash = { "name" => "bob", "id" => "2344" }
8
+ end
9
+
10
+ subject { described_class.new(@query_hash) }
11
+
12
+ it "converts the hash to a string" do
13
+ subject.to_s.should == "id=2344&name=bob"
14
+ end
15
+
16
+ it "raises a MissingQueryHashError if no hash is provided" do
17
+ lambda { described_class.new('') }.should raise_error(UriSigner::Errors::MissingQueryHashError)
18
+ end
19
+
20
+ it "raises a MissingQueryHashError if nil is provided" do
21
+ lambda { described_class.new(nil) }.should raise_error(UriSigner::Errors::MissingQueryHashError)
22
+ end
23
+
24
+ it "raises a MissingQueryHashError if a string is provided" do
25
+ lambda { described_class.new('name=bob') }.should raise_error(UriSigner::Errors::MissingQueryHashError)
26
+ end
27
+
28
+ it "allows a custom hash to be provided" do
29
+ hash = CustomHash.new
30
+ hash['name'] = 'bob'
31
+ lambda { described_class.new(hash) }.should_not raise_error
32
+ end
33
+
34
+ context "one param" do
35
+ before do
36
+ @query_hash = {"order"=>["name:desc", "id:desc"]}
37
+ end
38
+
39
+ subject { described_class.new(query_hash) }
40
+
41
+ it "converts a single key with multiple values to a string" do
42
+ query_hash = {"order"=>["name:desc", "id:desc"]}
43
+ parser = described_class.new(query_hash)
44
+
45
+ parser.to_s.should == "order=name:desc&order=id:desc"
46
+ end
47
+
48
+ it "converts a single key value to a string" do
49
+ query_hash = {"order"=>["name:desc"]}
50
+ parser = described_class.new(query_hash)
51
+
52
+ parser.to_s.should == "order=name:desc"
53
+ end
54
+ end
55
+
56
+ context "Parsing multiple params with different values" do
57
+ before do
58
+ @query_hash = {"order"=>["name:desc", "id:desc"], "_signature"=>"1234", "where"=>["name:nate", "id:123"]}
59
+ end
60
+
61
+ subject { described_class.new(@query_hash) }
62
+
63
+ it "converts the hash to a string" do
64
+ subject.to_s.should == "_signature=1234&order=name:desc&order=id:desc&where=name:nate&where=id:123"
65
+ end
66
+ end
67
+
68
+ context "Parsing multiple params with duplicate values" do
69
+ before do
70
+ @query_hash = {"order"=>["name:desc", "name:desc"], "_signature"=>"1234", "where"=>["name:nate", "name:nate"]}
71
+ end
72
+
73
+ subject { described_class.new(@query_hash) }
74
+
75
+ it "converts the hash to a string" do
76
+ subject.to_s.should == "_signature=1234&order=name:desc&order=name:desc&where=name:nate&where=name:nate"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,250 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '../lib/uri_signer'))
2
+
3
+ describe UriSigner::RequestParser do
4
+ before do
5
+ @method = 'GET'
6
+ @raw_uri = "https://api.example.com/core/people.json?_signature=1234&page=12&per_page=34&order=name:desc&select=id&select=guid"
7
+ end
8
+
9
+ subject { described_class.new(@method, @raw_uri) }
10
+
11
+ it "responds to #http_method" do
12
+ subject.should respond_to(:http_method)
13
+ end
14
+
15
+ it "responds to #https?" do
16
+ subject.should respond_to(:https?)
17
+ end
18
+
19
+ it "responds to #http?" do
20
+ subject.should respond_to(:http?)
21
+ end
22
+
23
+ it "responds to #raw_uri" do
24
+ subject.should respond_to(:raw_uri)
25
+ end
26
+
27
+ it "responds to #base_uri" do
28
+ subject.should respond_to(:base_uri)
29
+ end
30
+
31
+ it "responds to #parsed_uri" do
32
+ subject.should respond_to(:parsed_uri)
33
+ end
34
+
35
+ it "responds to #query_params" do
36
+ subject.should respond_to(:query_params)
37
+ end
38
+
39
+ it "responds to #query_params?" do
40
+ subject.should respond_to(:query_params?)
41
+ end
42
+
43
+ it "responds to #signature" do
44
+ subject.should respond_to(:signature)
45
+ end
46
+
47
+ it "responds to #signature?" do
48
+ subject.should respond_to(:signature?)
49
+ end
50
+
51
+ context "With HTML encoded URI String" do
52
+ before do
53
+ @raw_uri = "https://api.kissmetrics.dev/v1/accounts?page=2&amp;per_page=2&amp;_signature=lertaejeT%252BN3M1pCjBZo8gCRK%252BAfTmOquNvMZjmAfGw%253D"
54
+ end
55
+
56
+ subject { described_class.new(@method, @raw_uri) }
57
+
58
+ it "returns the #http_method" do
59
+ subject.http_method.should == "GET"
60
+ end
61
+
62
+ it "returns true for #https?" do
63
+ subject.https?.should be_true
64
+ end
65
+
66
+ it "returns the #base_uri" do
67
+ subject.base_uri.should == "https://api.kissmetrics.dev/v1/accounts"
68
+ end
69
+
70
+ it "returns the query values" do
71
+ subject.query_params.size.should eql(2)
72
+ end
73
+
74
+ it "does not return the signature in the query values" do
75
+ subject.query_params.should_not have_key('_signature')
76
+ end
77
+
78
+ it "returns true for #query_params?" do
79
+ subject.query_params?.should be_true
80
+ end
81
+
82
+ it "includes the proper keys in the query value" do
83
+ subject.query_params.should include('page', 'per_page')
84
+ end
85
+
86
+ it "returns the signature from the url" do
87
+ subject.signature.should == "lertaejeT%2BN3M1pCjBZo8gCRK%2BAfTmOquNvMZjmAfGw%3D"
88
+ end
89
+
90
+ it "returns true for #signature?" do
91
+ subject.signature?.should be_true
92
+ end
93
+ end
94
+
95
+ context "With valid values" do
96
+ it "returns the #http_method" do
97
+ subject.http_method.should == "GET"
98
+ end
99
+
100
+ it "returns true for #https?" do
101
+ subject.https?.should be_true
102
+ end
103
+
104
+ it "returns false for #http?" do
105
+ subject.http?.should be_false
106
+ end
107
+
108
+ it "returns the #base_uri" do
109
+ subject.base_uri.should == "https://api.example.com/core/people.json"
110
+ end
111
+
112
+ it "returns the #raw_uri" do
113
+ subject.raw_uri.should == "https://api.example.com/core/people.json?_signature=1234&page=12&per_page=34&order=name:desc&select=id&select=guid"
114
+ end
115
+
116
+ it "returns the query values" do
117
+ subject.query_params.size.should eql(4)
118
+ end
119
+
120
+ it "does not return the signature in the query values" do
121
+ subject.query_params.should_not have_key('_signature')
122
+ end
123
+
124
+ it "returns true for #query_params?" do
125
+ subject.query_params?.should be_true
126
+ end
127
+
128
+ it "includes the proper keys in the query value" do
129
+ subject.query_params.should include('page','per_page','order')
130
+ end
131
+
132
+ it "returns multiple values for query param key" do
133
+ subject.query_params['select'].should eql ['id', 'guid']
134
+ end
135
+
136
+ it "returns the parsed_uri as an Addressable::URI object" do
137
+ subject.parsed_uri.should be_a_kind_of(Addressable::URI)
138
+ end
139
+
140
+ it "returns the signature from the url" do
141
+ subject.signature.should == "1234"
142
+ end
143
+
144
+ it "returns true for #signature?" do
145
+ subject.signature?.should be_true
146
+ end
147
+ end
148
+
149
+ context "without query params" do
150
+ before do
151
+ @raw_uri = "https://api.example.com/core/people.json"
152
+ end
153
+
154
+ subject { described_class.new(@method, @raw_uri) }
155
+
156
+ it "returns empty query values" do
157
+ subject.query_params.size.should eql(0)
158
+ end
159
+
160
+ it "returns false for #signature?" do
161
+ subject.signature?.should be_false
162
+ end
163
+
164
+ it "returns false for #query_params?" do
165
+ subject.query_params?.should be_false
166
+ end
167
+ end
168
+
169
+ context "Determining the scheme" do
170
+ before do
171
+ @raw_uri = "http://api.example.com"
172
+ end
173
+
174
+ subject { described_class.new(@method, @raw_uri) }
175
+
176
+ it "returns false for #https?" do
177
+ subject.https?.should be_false
178
+ end
179
+
180
+ it "returns true for #http?" do
181
+ subject.http?.should be_true
182
+ end
183
+ end
184
+
185
+ context "With a signature and other query string values in the URI" do
186
+ before do
187
+ @raw_uri = "https://api.example.com/test?page=3&per_page=23&order=name:desc&_signature=3434343434gh="
188
+ end
189
+
190
+ subject { described_class.new(@method, @raw_uri) }
191
+
192
+ it "returns true for #signature?" do
193
+ subject.signature?.should be_true
194
+ end
195
+
196
+ it "escapes the signature" do
197
+ subject.signature.should == "3434343434gh%3D"
198
+ end
199
+ end
200
+
201
+ context "With a signature and no other query string values in the URI" do
202
+ before do
203
+ @raw_uri = "https://api.example.com/test?_signature=3434343434gh="
204
+ end
205
+
206
+ subject { described_class.new(@method, @raw_uri) }
207
+
208
+ it "returns true for #signature?" do
209
+ subject.signature?.should be_true
210
+ end
211
+
212
+ it "escapes the signature" do
213
+ subject.signature.should == "3434343434gh%3D"
214
+ end
215
+
216
+ it "returns empty query values" do
217
+ subject.query_params.size.should eql(0)
218
+ end
219
+
220
+ it "returns false for #query_params?" do
221
+ subject.query_params?.should be_false
222
+ end
223
+ end
224
+
225
+ context "Without a signature in the URI" do
226
+ before do
227
+ @raw_uri = "https://api.example.com"
228
+ end
229
+
230
+ subject { described_class.new(@method, @raw_uri) }
231
+
232
+ it "returns an empty string for #signature" do
233
+ subject.signature.should be_empty
234
+ end
235
+
236
+ it "returns false for #signature?" do
237
+ subject.signature?.should be_false
238
+ end
239
+ end
240
+
241
+ context "Validations" do
242
+ it "raises a UriSigner:MissingHttpMethodError if an empty string is provided" do
243
+ lambda { described_class.new('', @raw_uri)}.should raise_error(UriSigner::Errors::MissingHttpMethodError)
244
+ end
245
+
246
+ it "raises a UriSigner::MissingHttpMethodError if nil is provided" do
247
+ lambda { described_class.new(nil, @raw_uri)}.should raise_error(UriSigner::Errors::MissingHttpMethodError)
248
+ end
249
+ end
250
+ end