signature-acd 0.1.9
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.
- checksums.yaml +7 -0
- data/.gitignore +23 -0
- data/.travis.yml +18 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +41 -0
- data/LICENSE +20 -0
- data/README.md +65 -0
- data/Rakefile +2 -0
- data/lib/signature.rb +4 -0
- data/lib/signature/exceptions.rb +3 -0
- data/lib/signature/header.rb +31 -0
- data/lib/signature/query_encoder.rb +47 -0
- data/lib/signature/request.rb +242 -0
- data/lib/signature/token.rb +13 -0
- data/lib/signature/version.rb +3 -0
- data/signature.gemspec +24 -0
- data/spec/signature_spec.rb +332 -0
- data/spec/spec_helper.rb +10 -0
- metadata +105 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: bb3d0b4d61f9b917ea4ff7b3767ee3b3fabd276b
|
4
|
+
data.tar.gz: 94b38fe0aca3221961c9919dd57e6cda345ac5fd
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fc32e214764d82a4e18266a0ec90831c64587507d57fc0c49332449f7dbeb0b4bef74db5be0f7d5b17175c66e0ef6b6a844a5571cdceb2f62c428d7ac3aeee1d
|
7
|
+
data.tar.gz: 68f96770675a1ec5ff91e57c09a72b79438ec9f1111e11468a90bc6146496bea6af2f3fc6e0f7420bf4ec3f068a39a4058ef6f4b12bcc8bb756158b23e5fb7e6
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
language: ruby
|
2
|
+
rvm:
|
3
|
+
- 1.8.7
|
4
|
+
- 1.9.2
|
5
|
+
- 1.9.3
|
6
|
+
- 2.0.0
|
7
|
+
- jruby-18mode
|
8
|
+
- jruby-19mode
|
9
|
+
- rbx-18mode
|
10
|
+
- rbx-19mode
|
11
|
+
matrix:
|
12
|
+
allow_failures:
|
13
|
+
- rvm: jruby-18mode
|
14
|
+
- rvm: jruby-19mode
|
15
|
+
- rvm: rbx-18mode
|
16
|
+
- rvm: rbx-19mode
|
17
|
+
|
18
|
+
script: bundle exec rspec spec
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
signature (0.1.9)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
bacon (1.2.0)
|
10
|
+
coderay (1.1.0)
|
11
|
+
diff-lcs (1.2.5)
|
12
|
+
em-spec (0.2.6)
|
13
|
+
bacon
|
14
|
+
eventmachine
|
15
|
+
rspec (> 2.6.0)
|
16
|
+
test-unit
|
17
|
+
eventmachine (1.0.3)
|
18
|
+
method_source (0.8.2)
|
19
|
+
pry (0.9.12.6)
|
20
|
+
coderay (~> 1.0)
|
21
|
+
method_source (~> 0.8)
|
22
|
+
slop (~> 3.4)
|
23
|
+
rspec (2.14.1)
|
24
|
+
rspec-core (~> 2.14.0)
|
25
|
+
rspec-expectations (~> 2.14.0)
|
26
|
+
rspec-mocks (~> 2.14.0)
|
27
|
+
rspec-core (2.14.8)
|
28
|
+
rspec-expectations (2.14.5)
|
29
|
+
diff-lcs (>= 1.1.3, < 2.0)
|
30
|
+
rspec-mocks (2.14.6)
|
31
|
+
slop (3.5.0)
|
32
|
+
test-unit (2.5.5)
|
33
|
+
|
34
|
+
PLATFORMS
|
35
|
+
ruby
|
36
|
+
|
37
|
+
DEPENDENCIES
|
38
|
+
em-spec
|
39
|
+
pry
|
40
|
+
rspec
|
41
|
+
signature!
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Martyn Loughran
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
signature
|
2
|
+
=========
|
3
|
+
|
4
|
+
[](http://travis-ci.org/mloughran/signature)
|
5
|
+
|
6
|
+
Examples
|
7
|
+
--------
|
8
|
+
|
9
|
+
Client example
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
params = {:some => 'parameters'}
|
13
|
+
token = Signature::Token.new('my_key', 'my_secret')
|
14
|
+
request = Signature::Request.new('POST', '/api/thing', params)
|
15
|
+
auth_hash = request.sign(token)
|
16
|
+
query_params = params.merge(auth_hash)
|
17
|
+
|
18
|
+
HTTParty.post('http://myservice/api/thing', {
|
19
|
+
:query => query_params
|
20
|
+
})
|
21
|
+
```
|
22
|
+
|
23
|
+
`query_params` looks like:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
{
|
27
|
+
:some => "parameters",
|
28
|
+
:auth_timestamp => 1273231888,
|
29
|
+
:auth_signature => "28b6bb0f242f71064916fad6ae463fe91f5adc302222dfc02c348ae1941eaf80",
|
30
|
+
:auth_version => "1.0",
|
31
|
+
:auth_key => "my_key"
|
32
|
+
}
|
33
|
+
|
34
|
+
```
|
35
|
+
Server example (sinatra)
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
error Signature::AuthenticationError do |controller|
|
39
|
+
error = controller.env["sinatra.error"]
|
40
|
+
halt 401, "401 UNAUTHORIZED: #{error.message}\n"
|
41
|
+
end
|
42
|
+
|
43
|
+
post '/api/thing' do
|
44
|
+
request = Signature::Request.new('POST', env["REQUEST_PATH"], params)
|
45
|
+
# This will raise a Signature::AuthenticationError if request does not authenticate
|
46
|
+
token = request.authenticate do |key|
|
47
|
+
Signature::Token.new(key, lookup_secret(key))
|
48
|
+
end
|
49
|
+
|
50
|
+
# Do whatever you need to do
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
Developing
|
55
|
+
----------
|
56
|
+
|
57
|
+
bundle
|
58
|
+
bundle exec rspec spec/*_spec.rb
|
59
|
+
|
60
|
+
Please see the travis status for a list of rubies tested against
|
61
|
+
|
62
|
+
Copyright
|
63
|
+
---------
|
64
|
+
|
65
|
+
Copyright (c) 2010 Martyn Loughran. See LICENSE for details.
|
data/Rakefile
ADDED
data/lib/signature.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
module Signature
|
2
|
+
module Header
|
3
|
+
class Request < ::Signature::Request
|
4
|
+
AUTH_HEADER_PREFIX = "X-API-"
|
5
|
+
AUTH_HEADER_PREFIX_REGEX = /^X\-API\-(AUTH\-.+)$/
|
6
|
+
|
7
|
+
def self.parse_headers headers={}
|
8
|
+
hh = {}
|
9
|
+
headers.each do |k,v|
|
10
|
+
if match = k.upcase.match(AUTH_HEADER_PREFIX_REGEX)
|
11
|
+
hh[match[1].downcase.gsub!('-', '_')] = v
|
12
|
+
end
|
13
|
+
end
|
14
|
+
hh
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize method, path, query={}, headers={}
|
18
|
+
auth_hash = self.class.parse_headers(headers)
|
19
|
+
super(method, path, query.merge(auth_hash))
|
20
|
+
end
|
21
|
+
|
22
|
+
def sign token
|
23
|
+
auth_hash = super(token)
|
24
|
+
auth_hash.inject({}) do |memo, (k,v)|
|
25
|
+
memo["#{AUTH_HEADER_PREFIX}#{k.to_s.upcase.gsub('_', '-')}"] = v
|
26
|
+
memo
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Signature
|
2
|
+
# Query string encoding extracted with thanks from em-http-request
|
3
|
+
module QueryEncoder
|
4
|
+
class << self
|
5
|
+
# URL encodes query parameters:
|
6
|
+
# single k=v, or a URL encoded array, if v is an array of values
|
7
|
+
def encode_param(k, v)
|
8
|
+
if v.is_a?(Array)
|
9
|
+
v.map { |e| escape(k) + "[]=" + escape(e) }.join("&")
|
10
|
+
else
|
11
|
+
escape(k) + "=" + escape(v)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Like encode_param, but doesn't url escape keys or values
|
16
|
+
def encode_param_without_escaping(k, v)
|
17
|
+
if v.is_a?(Array)
|
18
|
+
v.map { |e| k + "[]=" + e }.join("&")
|
19
|
+
else
|
20
|
+
"#{k}=#{v}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def escape(s)
|
27
|
+
if defined?(EscapeUtils)
|
28
|
+
EscapeUtils.escape_url(s.to_s)
|
29
|
+
else
|
30
|
+
s.to_s.gsub(/([^a-zA-Z0-9_.-]+)/n) {
|
31
|
+
'%'+$1.unpack('H2'*bytesize($1)).join('%').upcase
|
32
|
+
}
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
if ''.respond_to?(:bytesize)
|
37
|
+
def bytesize(string)
|
38
|
+
string.bytesize
|
39
|
+
end
|
40
|
+
else
|
41
|
+
def bytesize(string)
|
42
|
+
string.size
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Signature
|
5
|
+
class Request
|
6
|
+
AUTH_HEADER_PREFIX = "X-API-"
|
7
|
+
AUTH_HEADER_PREFIX_REGEX = /^X\-API\-(AUTH\-.+)$/
|
8
|
+
|
9
|
+
attr_accessor :path, :query_hash
|
10
|
+
|
11
|
+
include QueryEncoder
|
12
|
+
|
13
|
+
# http://www.w3.org/TR/NOTE-datetime
|
14
|
+
ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
|
15
|
+
|
16
|
+
def initialize(method, path, query, headers=nil)
|
17
|
+
raise ArgumentError, "Expected string" unless path.kind_of?(String)
|
18
|
+
raise ArgumentError, "Expected hash" unless query.kind_of?(Hash)
|
19
|
+
|
20
|
+
query_hash = {}
|
21
|
+
|
22
|
+
auth_hash = self.class.parse_headers(headers) if headers
|
23
|
+
auth_hash ||= {}
|
24
|
+
|
25
|
+
query.each do |key, v|
|
26
|
+
k = key.to_s.downcase
|
27
|
+
k[0..4] == 'auth_' ? auth_hash[k] = v : query_hash[k] = v
|
28
|
+
end
|
29
|
+
|
30
|
+
@method = method.upcase
|
31
|
+
@path, @query_hash, @auth_hash = path, query_hash, auth_hash
|
32
|
+
@signed = false
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.parse_headers headers={}
|
36
|
+
hh = {}
|
37
|
+
headers.each do |k,v|
|
38
|
+
if match = k.upcase.match(AUTH_HEADER_PREFIX_REGEX)
|
39
|
+
hh[match[1].downcase.gsub!('-', '_')] = v
|
40
|
+
end
|
41
|
+
end
|
42
|
+
hh
|
43
|
+
end
|
44
|
+
|
45
|
+
# Sign the request with the given token, and return the computed
|
46
|
+
# authentication parameters
|
47
|
+
#
|
48
|
+
def sign(token)
|
49
|
+
@auth_hash = {
|
50
|
+
:auth_version => "1.0",
|
51
|
+
:auth_key => token.key,
|
52
|
+
:auth_timestamp => Time.now.to_i.to_s
|
53
|
+
}
|
54
|
+
@auth_hash[:auth_signature] = signature(token)
|
55
|
+
|
56
|
+
@signed = true
|
57
|
+
|
58
|
+
return @auth_hash
|
59
|
+
end
|
60
|
+
|
61
|
+
# Authenticates the request with a token
|
62
|
+
#
|
63
|
+
# Raises an AuthenticationError if the request is invalid.
|
64
|
+
# AuthenticationError exception messages are designed to be exposed to API
|
65
|
+
# consumers, and should help them correct errors generating signatures
|
66
|
+
#
|
67
|
+
# Timestamp: Unless timestamp_grace is set to nil (which allows this check
|
68
|
+
# to be skipped), AuthenticationError will be raised if the timestamp is
|
69
|
+
# missing or further than timestamp_grace period away from the real time
|
70
|
+
# (defaults to 10 minutes)
|
71
|
+
#
|
72
|
+
# Signature: Raises AuthenticationError if the signature does not match
|
73
|
+
# the computed HMAC. The error contains a hint for how to sign.
|
74
|
+
#
|
75
|
+
def authenticate_by_token!(token, timestamp_grace = 600)
|
76
|
+
# Validate that your code has provided a valid token. This does not
|
77
|
+
# raise an AuthenticationError since passing tokens with empty secret is
|
78
|
+
# a code error which should be fixed, not reported to the API's consumer
|
79
|
+
if token.secret.nil? || token.secret.empty?
|
80
|
+
raise "Provided token is missing secret"
|
81
|
+
end
|
82
|
+
|
83
|
+
validate_version!
|
84
|
+
validate_timestamp!(timestamp_grace)
|
85
|
+
validate_signature!(token)
|
86
|
+
true
|
87
|
+
end
|
88
|
+
|
89
|
+
# Authenticate the request with a token, but rather than raising an
|
90
|
+
# exception if the request is invalid, simply returns false
|
91
|
+
#
|
92
|
+
def authenticate_by_token(token, timestamp_grace = 600)
|
93
|
+
authenticate_by_token!(token, timestamp_grace)
|
94
|
+
rescue AuthenticationError
|
95
|
+
false
|
96
|
+
end
|
97
|
+
|
98
|
+
# Authenticate a request
|
99
|
+
#
|
100
|
+
# Takes a block which will be called with the auth_key from the request,
|
101
|
+
# and which should return a Signature::Token (or nil if no token can be
|
102
|
+
# found for the key)
|
103
|
+
#
|
104
|
+
# Raises errors in the same way as authenticate_by_token!
|
105
|
+
#
|
106
|
+
def authenticate(timestamp_grace = 600)
|
107
|
+
raise ArgumentError, "Block required" unless block_given?
|
108
|
+
key = @auth_hash['auth_key']
|
109
|
+
raise AuthenticationError, "Missing parameter: auth_key" unless key
|
110
|
+
token = yield key
|
111
|
+
unless token
|
112
|
+
raise AuthenticationError, "Unknown auth_key"
|
113
|
+
end
|
114
|
+
authenticate_by_token!(token, timestamp_grace)
|
115
|
+
return token
|
116
|
+
end
|
117
|
+
|
118
|
+
# Authenticate a request asynchronously
|
119
|
+
#
|
120
|
+
# This method is useful it you're running a server inside eventmachine and
|
121
|
+
# need to lookup the token asynchronously.
|
122
|
+
#
|
123
|
+
# The block is passed an auth key and a deferrable which should succeed
|
124
|
+
# with the token, or fail if the token cannot be found
|
125
|
+
#
|
126
|
+
# This method returns a deferrable which succeeds with the valid token, or
|
127
|
+
# fails with an AuthenticationError which can be used to pass the error
|
128
|
+
# back to the user
|
129
|
+
#
|
130
|
+
def authenticate_async(timestamp_grace = 600)
|
131
|
+
raise ArgumentError, "Block required" unless block_given?
|
132
|
+
df = EM::DefaultDeferrable.new
|
133
|
+
|
134
|
+
key = @auth_hash['auth_key']
|
135
|
+
|
136
|
+
unless key
|
137
|
+
df.fail(AuthenticationError.new("Missing parameter: auth_key"))
|
138
|
+
return
|
139
|
+
end
|
140
|
+
|
141
|
+
token_df = yield key
|
142
|
+
token_df.callback { |token|
|
143
|
+
begin
|
144
|
+
authenticate_by_token!(token, timestamp_grace)
|
145
|
+
df.succeed(token)
|
146
|
+
rescue AuthenticationError => e
|
147
|
+
df.fail(e)
|
148
|
+
end
|
149
|
+
}
|
150
|
+
token_df.errback {
|
151
|
+
df.fail(AuthenticationError.new("Unknown auth_key"))
|
152
|
+
}
|
153
|
+
ensure
|
154
|
+
return df
|
155
|
+
end
|
156
|
+
|
157
|
+
# Expose the authentication parameters for a signed request
|
158
|
+
#
|
159
|
+
def auth_hash
|
160
|
+
raise "Request not signed" unless @signed
|
161
|
+
@auth_hash
|
162
|
+
end
|
163
|
+
|
164
|
+
# Query parameters merged with the computed authentication parameters
|
165
|
+
#
|
166
|
+
def signed_params
|
167
|
+
@query_hash.merge(auth_hash)
|
168
|
+
end
|
169
|
+
|
170
|
+
private
|
171
|
+
|
172
|
+
def signature(token)
|
173
|
+
digest = OpenSSL::Digest::SHA256.new
|
174
|
+
OpenSSL::HMAC.hexdigest(digest, token.secret, string_to_sign)
|
175
|
+
end
|
176
|
+
|
177
|
+
def string_to_sign
|
178
|
+
auth_hash = @auth_hash || {}
|
179
|
+
{
|
180
|
+
api: {
|
181
|
+
method: @method,
|
182
|
+
path: @path,
|
183
|
+
timestamp: auth_hash[:auth_timestamp] || auth_hash["auth_timestamp"],
|
184
|
+
version: auth_hash[:auth_version] || auth_hash["auth_version"]
|
185
|
+
},
|
186
|
+
params: parameters_sorted
|
187
|
+
}.to_json
|
188
|
+
end
|
189
|
+
|
190
|
+
def parameters_sorted
|
191
|
+
shash = {}
|
192
|
+
@query_hash.sort.map do |k, v|
|
193
|
+
shash[k.to_s.downcase] = v
|
194
|
+
end
|
195
|
+
shash
|
196
|
+
end
|
197
|
+
|
198
|
+
# def parameter_string
|
199
|
+
# param_hash = @query_hash.merge(@auth_hash || {})
|
200
|
+
#
|
201
|
+
# # Convert keys to lowercase strings
|
202
|
+
# hash = {}; param_hash.each { |k,v| hash[k.to_s.downcase] = v }
|
203
|
+
#
|
204
|
+
# # Exclude signature from signature generation!
|
205
|
+
# hash.delete("auth_signature")
|
206
|
+
#
|
207
|
+
# hash.sort.map do |k, v|
|
208
|
+
# QueryEncoder.encode_param_without_escaping(k, v)
|
209
|
+
# end.join('&')
|
210
|
+
# end
|
211
|
+
|
212
|
+
def validate_version!
|
213
|
+
version = @auth_hash["auth_version"]
|
214
|
+
raise AuthenticationError, "Version required" unless version
|
215
|
+
raise AuthenticationError, "Version not supported" unless version == '1.0'
|
216
|
+
end
|
217
|
+
|
218
|
+
def validate_timestamp!(grace)
|
219
|
+
return true if grace.nil?
|
220
|
+
|
221
|
+
timestamp = @auth_hash["auth_timestamp"]
|
222
|
+
error = (timestamp.to_i - Time.now.to_i).abs
|
223
|
+
raise AuthenticationError, "Timestamp required" unless timestamp
|
224
|
+
if error >= grace
|
225
|
+
raise AuthenticationError, "Timestamp expired: Given timestamp "\
|
226
|
+
"(#{Time.at(timestamp.to_i).utc.strftime(ISO8601)}) "\
|
227
|
+
"not within #{grace}s of server time "\
|
228
|
+
"(#{Time.now.utc.strftime(ISO8601)})"
|
229
|
+
end
|
230
|
+
return true
|
231
|
+
end
|
232
|
+
|
233
|
+
def validate_signature!(token)
|
234
|
+
unless @auth_hash["auth_signature"] == signature(token)
|
235
|
+
raise AuthenticationError, "Invalid signature: you should have "\
|
236
|
+
"sent HmacSHA256Hex(#{string_to_sign.inspect}, your_secret_key)"\
|
237
|
+
", but you sent #{@auth_hash["auth_signature"].inspect}"
|
238
|
+
end
|
239
|
+
return true
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
data/signature.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "signature/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "signature-acd"
|
7
|
+
s.version = Signature::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Thomas Hanson", "CJ Lazell", "Thomas Hanson"]
|
10
|
+
s.email = ["thanson@acdcorp.com"]
|
11
|
+
s.homepage = "http://github.com/acdcorp/signature"
|
12
|
+
s.summary = %q{Simple key/secret based authentication for apis}
|
13
|
+
s.description = %q{Simple key/secret based authentication for apis}
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
|
20
|
+
s.add_dependency "jruby-openssl" if defined?(JRUBY_VERSION)
|
21
|
+
s.add_development_dependency "rspec"
|
22
|
+
s.add_development_dependency "em-spec"
|
23
|
+
s.add_development_dependency "pry"
|
24
|
+
end
|
@@ -0,0 +1,332 @@
|
|
1
|
+
require File.expand_path('../spec_helper', __FILE__)
|
2
|
+
|
3
|
+
describe Signature do
|
4
|
+
before :each do
|
5
|
+
Time.stub!(:now).and_return(Time.at(1234))
|
6
|
+
|
7
|
+
@token = Signature::Token.new('key', 'secret')
|
8
|
+
|
9
|
+
@request = Signature::Request.new('POST', '/some/path', {
|
10
|
+
"query" => "params",
|
11
|
+
"go" => "here"
|
12
|
+
})
|
13
|
+
|
14
|
+
@headers = {
|
15
|
+
"X-API-AUTH-VERSION" => "1.0",
|
16
|
+
"X-API-AUTH-KEY" => "key",
|
17
|
+
"X-API-AUTH-SIGNATURE" => "9a86683edaf7db6782ac2d78d1958f3d53fa6aeb4c80542335ac64ee5e926411",
|
18
|
+
"X-API-AUTH-TIMESTAMP" => "3456"
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "generating signatures" do
|
23
|
+
before :each do
|
24
|
+
@signature = "9a86683edaf7db6782ac2d78d1958f3d53fa6aeb4c80542335ac64ee5e926411"
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should generate signature correctly" do
|
28
|
+
@request.sign(@token)
|
29
|
+
string = @request.send(:string_to_sign)
|
30
|
+
# string.should == "POST\n/some/path\nauth_key=key&auth_timestamp=1234&auth_version=1.0&go=here&query=params"
|
31
|
+
string.should == {
|
32
|
+
api: {
|
33
|
+
method: "POST",
|
34
|
+
path: "/some/path",
|
35
|
+
timestamp: "1234",
|
36
|
+
version: "1.0"
|
37
|
+
},
|
38
|
+
params: {
|
39
|
+
go: "here",
|
40
|
+
query: "params"
|
41
|
+
}
|
42
|
+
}.to_json
|
43
|
+
|
44
|
+
# "{\"api\":{\"method\":\"POST\",\"path\":\"/some/path\",\"timestamp\":null,\"version\":null},\"params\":{\"query\":\"params\",\"go\":\"here\"}}"
|
45
|
+
|
46
|
+
digest = OpenSSL::Digest::SHA256.new
|
47
|
+
signature = OpenSSL::HMAC.hexdigest(digest, @token.secret, string)
|
48
|
+
signature.should == @signature
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should make auth_hash available after request is signed" do
|
52
|
+
@request.query_hash = {
|
53
|
+
"query" => "params"
|
54
|
+
}
|
55
|
+
lambda {
|
56
|
+
@request.auth_hash
|
57
|
+
}.should raise_error('Request not signed')
|
58
|
+
|
59
|
+
@request.sign(@token)
|
60
|
+
@request.auth_hash.should == {
|
61
|
+
:auth_signature => "e4b1eee7fbe9beb5aebcd918b45d53c76a69157e8e3575d636a370b5afb3c662",
|
62
|
+
:auth_version => "1.0",
|
63
|
+
:auth_key => "key",
|
64
|
+
:auth_timestamp => '1234'
|
65
|
+
}
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should cope with symbol keys" do
|
69
|
+
@request.query_hash = {
|
70
|
+
:query => "params",
|
71
|
+
:go => "here"
|
72
|
+
}
|
73
|
+
@request.sign(@token)[:auth_signature].should == @signature
|
74
|
+
end
|
75
|
+
|
76
|
+
it "should cope with upcase keys (keys are lowercased before signing)" do
|
77
|
+
@request.query_hash = {
|
78
|
+
"Query" => "params",
|
79
|
+
"GO" => "here"
|
80
|
+
}
|
81
|
+
@request.sign(@token)[:auth_signature].should == @signature
|
82
|
+
end
|
83
|
+
|
84
|
+
it "should generate correct string when query hash contains array" do
|
85
|
+
@request.query_hash = {
|
86
|
+
"things" => ["thing1", "thing2"]
|
87
|
+
}
|
88
|
+
@request.send(:string_to_sign).should == "{\"api\":{\"method\":\"POST\",\"path\":\"/some/path\",\"timestamp\":null,\"version\":null},\"params\":{\"things\":[\"thing1\",\"thing2\"]}}"
|
89
|
+
end
|
90
|
+
|
91
|
+
# This may well change in auth version 2
|
92
|
+
it "should not escape keys or values in the query string" do
|
93
|
+
@request.query_hash = {
|
94
|
+
"key;" => "value@"
|
95
|
+
}
|
96
|
+
@request.send(:string_to_sign).should == "{\"api\":{\"method\":\"POST\",\"path\":\"/some/path\",\"timestamp\":null,\"version\":null},\"params\":{\"key;\":\"value@\"}}"
|
97
|
+
end
|
98
|
+
|
99
|
+
it "should cope with requests where the value is nil (antiregression)" do
|
100
|
+
@request.query_hash = {
|
101
|
+
"key" => nil
|
102
|
+
}
|
103
|
+
@request.send(:string_to_sign).should == "{\"api\":{\"method\":\"POST\",\"path\":\"/some/path\",\"timestamp\":null,\"version\":null},\"params\":{\"key\":null}}"
|
104
|
+
end
|
105
|
+
|
106
|
+
it "should use the path to generate signature" do
|
107
|
+
@request.path = '/some/other/path'
|
108
|
+
@request.sign(@token)[:auth_signature].should_not == @signature
|
109
|
+
end
|
110
|
+
|
111
|
+
it "should use the query string keys to generate signature" do
|
112
|
+
@request.query_hash = {
|
113
|
+
"other" => "query"
|
114
|
+
}
|
115
|
+
@request.sign(@token)[:auth_signature].should_not == @signature
|
116
|
+
end
|
117
|
+
|
118
|
+
it "should use the query string values to generate signature" do
|
119
|
+
@request.query_hash = {
|
120
|
+
"key" => "notfoo",
|
121
|
+
"other" => 'bar'
|
122
|
+
}
|
123
|
+
@request.sign(@token)[:signature].should_not == @signature
|
124
|
+
end
|
125
|
+
|
126
|
+
it "should accept authentication parameters via HTTP headers" do
|
127
|
+
request = Signature::Request.new('POST', '/some/path/with/headers', {
|
128
|
+
"query" => "params",
|
129
|
+
"go" => "here"
|
130
|
+
}, @headers)
|
131
|
+
|
132
|
+
string = request.send(:string_to_sign)
|
133
|
+
string.should == {
|
134
|
+
api: {
|
135
|
+
method: "POST",
|
136
|
+
path: "/some/path/with/headers",
|
137
|
+
timestamp: "3456",
|
138
|
+
version: "1.0"
|
139
|
+
},
|
140
|
+
params: {
|
141
|
+
go: "here",
|
142
|
+
query: "params"
|
143
|
+
}
|
144
|
+
}.to_json
|
145
|
+
|
146
|
+
digest = OpenSSL::Digest::SHA256.new
|
147
|
+
signature = OpenSSL::HMAC.hexdigest(digest, @token.secret, string)
|
148
|
+
signature.should == "002b9f68b311a172995cb2f9c8ce3954b5adb37c721d501afae55a150f2f608d"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
describe "verification" do
|
153
|
+
before :each do
|
154
|
+
@request.sign(@token)
|
155
|
+
@params = @request.signed_params
|
156
|
+
end
|
157
|
+
|
158
|
+
it "should verify requests" do
|
159
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
160
|
+
request.authenticate_by_token(@token).should == true
|
161
|
+
end
|
162
|
+
|
163
|
+
it "should raise error if signature is not correct" do
|
164
|
+
@params[:auth_signature] = 'asdf'
|
165
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
166
|
+
lambda {
|
167
|
+
request.authenticate_by_token!(@token)
|
168
|
+
}.should raise_error('Invalid signature: you should have sent HmacSHA256Hex("{\"api\":{\"method\":\"POST\",\"path\":\"/some/path\",\"timestamp\":\"1234\",\"version\":\"1.0\"},\"params\":{\"go\":\"here\",\"query\":\"params\"}}", your_secret_key), but you sent "asdf"')
|
169
|
+
end
|
170
|
+
|
171
|
+
it "should raise error if timestamp not available" do
|
172
|
+
@params.delete(:auth_timestamp)
|
173
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
174
|
+
lambda {
|
175
|
+
request.authenticate_by_token!(@token)
|
176
|
+
}.should raise_error('Timestamp required')
|
177
|
+
end
|
178
|
+
|
179
|
+
it "should raise error if timestamp has expired (default of 600s)" do
|
180
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
181
|
+
Time.stub!(:now).and_return(Time.at(1234 + 599))
|
182
|
+
request.authenticate_by_token!(@token).should == true
|
183
|
+
Time.stub!(:now).and_return(Time.at(1234 - 599))
|
184
|
+
request.authenticate_by_token!(@token).should == true
|
185
|
+
Time.stub!(:now).and_return(Time.at(1234 + 600))
|
186
|
+
lambda {
|
187
|
+
request.authenticate_by_token!(@token)
|
188
|
+
}.should raise_error("Timestamp expired: Given timestamp (1970-01-01T00:20:34Z) not within 600s of server time (1970-01-01T00:30:34Z)")
|
189
|
+
Time.stub!(:now).and_return(Time.at(1234 - 600))
|
190
|
+
lambda {
|
191
|
+
request.authenticate_by_token!(@token)
|
192
|
+
}.should raise_error("Timestamp expired: Given timestamp (1970-01-01T00:20:34Z) not within 600s of server time (1970-01-01T00:10:34Z)")
|
193
|
+
end
|
194
|
+
|
195
|
+
it "should be possible to customize the timeout grace period" do
|
196
|
+
grace = 10
|
197
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
198
|
+
Time.stub!(:now).and_return(Time.at(1234 + grace - 1))
|
199
|
+
request.authenticate_by_token!(@token, grace).should == true
|
200
|
+
Time.stub!(:now).and_return(Time.at(1234 + grace))
|
201
|
+
lambda {
|
202
|
+
request.authenticate_by_token!(@token, grace)
|
203
|
+
}.should raise_error("Timestamp expired: Given timestamp (1970-01-01T00:20:34Z) not within 10s of server time (1970-01-01T00:20:44Z)")
|
204
|
+
end
|
205
|
+
|
206
|
+
it "should be possible to skip timestamp check by passing nil" do
|
207
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
208
|
+
Time.stub!(:now).and_return(Time.at(1234 + 1000))
|
209
|
+
request.authenticate_by_token!(@token, nil).should == true
|
210
|
+
end
|
211
|
+
|
212
|
+
it "should check that auth_version is supplied" do
|
213
|
+
@params.delete(:auth_version)
|
214
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
215
|
+
lambda {
|
216
|
+
request.authenticate_by_token!(@token)
|
217
|
+
}.should raise_error('Version required')
|
218
|
+
end
|
219
|
+
|
220
|
+
it "should check that auth_version equals 1.0" do
|
221
|
+
@params[:auth_version] = '1.1'
|
222
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
223
|
+
lambda {
|
224
|
+
request.authenticate_by_token!(@token)
|
225
|
+
}.should raise_error('Version not supported')
|
226
|
+
end
|
227
|
+
|
228
|
+
it "should validate that the provided token has a non-empty secret" do
|
229
|
+
token = Signature::Token.new('key', '')
|
230
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
231
|
+
|
232
|
+
lambda {
|
233
|
+
request.authenticate_by_token!(token)
|
234
|
+
}.should raise_error('Provided token is missing secret')
|
235
|
+
end
|
236
|
+
|
237
|
+
describe "when used with optional block" do
|
238
|
+
it "should optionally take a block which yields the signature" do
|
239
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
240
|
+
request.authenticate do |key|
|
241
|
+
key.should == @token.key
|
242
|
+
@token
|
243
|
+
end.should == @token
|
244
|
+
end
|
245
|
+
|
246
|
+
it "should raise error if no auth_key supplied to request" do
|
247
|
+
@params.delete(:auth_key)
|
248
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
249
|
+
lambda {
|
250
|
+
request.authenticate { |key| nil }
|
251
|
+
}.should raise_error('Missing parameter: auth_key')
|
252
|
+
end
|
253
|
+
|
254
|
+
it "should raise error if block returns nil (i.e. key doesn't exist)" do
|
255
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
256
|
+
lambda {
|
257
|
+
request.authenticate { |key| nil }
|
258
|
+
}.should raise_error('Unknown auth_key')
|
259
|
+
end
|
260
|
+
|
261
|
+
it "should raise unless block given" do
|
262
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
263
|
+
lambda {
|
264
|
+
request.authenticate
|
265
|
+
}.should raise_error(ArgumentError, "Block required")
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
describe "authenticate_async" do
|
270
|
+
include EM::SpecHelper
|
271
|
+
default_timeout 1
|
272
|
+
|
273
|
+
it "returns a deferrable which succeeds if authentication passes" do
|
274
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
275
|
+
em {
|
276
|
+
df = EM::DefaultDeferrable.new
|
277
|
+
|
278
|
+
request_df = request.authenticate_async do |key|
|
279
|
+
df
|
280
|
+
end
|
281
|
+
|
282
|
+
df.succeed(@token)
|
283
|
+
|
284
|
+
request_df.callback { |token|
|
285
|
+
token.should == @token
|
286
|
+
done
|
287
|
+
}
|
288
|
+
}
|
289
|
+
end
|
290
|
+
|
291
|
+
it "returns a deferrable which fails if block df fails" do
|
292
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
293
|
+
em {
|
294
|
+
df = EM::DefaultDeferrable.new
|
295
|
+
|
296
|
+
request_df = request.authenticate_async do |key|
|
297
|
+
df
|
298
|
+
end
|
299
|
+
|
300
|
+
df.fail()
|
301
|
+
|
302
|
+
request_df.errback { |e|
|
303
|
+
e.class.should == Signature::AuthenticationError
|
304
|
+
e.message.should == 'Unknown auth_key'
|
305
|
+
done
|
306
|
+
}
|
307
|
+
}
|
308
|
+
end
|
309
|
+
|
310
|
+
it "returns a deferrable which fails if request does not validate" do
|
311
|
+
request = Signature::Request.new('POST', '/some/path', @params)
|
312
|
+
em {
|
313
|
+
df = EM::DefaultDeferrable.new
|
314
|
+
|
315
|
+
request_df = request.authenticate_async do |key|
|
316
|
+
df
|
317
|
+
end
|
318
|
+
|
319
|
+
token = Signature::Token.new('key', 'wrong_secret')
|
320
|
+
df.succeed(token)
|
321
|
+
|
322
|
+
request_df.errback { |e|
|
323
|
+
e.class.should == Signature::AuthenticationError
|
324
|
+
e.message.should =~ /Invalid signature/
|
325
|
+
done
|
326
|
+
}
|
327
|
+
}
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
end
|
332
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: signature-acd
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.9
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Thomas Hanson
|
8
|
+
- CJ Lazell
|
9
|
+
- Thomas Hanson
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2014-05-20 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rspec
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
requirements:
|
19
|
+
- - ">="
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '0'
|
29
|
+
- !ruby/object:Gem::Dependency
|
30
|
+
name: em-spec
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
32
|
+
requirements:
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: '0'
|
36
|
+
type: :development
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: pry
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
type: :development
|
51
|
+
prerelease: false
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
description: Simple key/secret based authentication for apis
|
58
|
+
email:
|
59
|
+
- thanson@acdcorp.com
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files: []
|
63
|
+
files:
|
64
|
+
- ".gitignore"
|
65
|
+
- ".travis.yml"
|
66
|
+
- Gemfile
|
67
|
+
- Gemfile.lock
|
68
|
+
- LICENSE
|
69
|
+
- README.md
|
70
|
+
- Rakefile
|
71
|
+
- lib/signature.rb
|
72
|
+
- lib/signature/exceptions.rb
|
73
|
+
- lib/signature/header.rb
|
74
|
+
- lib/signature/query_encoder.rb
|
75
|
+
- lib/signature/request.rb
|
76
|
+
- lib/signature/token.rb
|
77
|
+
- lib/signature/version.rb
|
78
|
+
- signature.gemspec
|
79
|
+
- spec/signature_spec.rb
|
80
|
+
- spec/spec_helper.rb
|
81
|
+
homepage: http://github.com/acdcorp/signature
|
82
|
+
licenses: []
|
83
|
+
metadata: {}
|
84
|
+
post_install_message:
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubyforge_project:
|
100
|
+
rubygems_version: 2.2.2
|
101
|
+
signing_key:
|
102
|
+
specification_version: 4
|
103
|
+
summary: Simple key/secret based authentication for apis
|
104
|
+
test_files: []
|
105
|
+
has_rdoc:
|