api-auth 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- api-auth (1.0.0)
4
+ api-auth (1.0.1)
5
5
 
6
6
  GEM
7
7
  remote: http://rubygems.org/
@@ -12,10 +12,13 @@ GEM
12
12
  activeresource (2.3.14)
13
13
  activesupport (= 2.3.14)
14
14
  activesupport (2.3.14)
15
+ amatch (0.2.10)
16
+ tins (~> 0.3)
15
17
  curb (0.8.1)
16
18
  diff-lcs (1.1.3)
17
19
  mime-types (1.17.2)
18
20
  rack (1.1.3)
21
+ rake (0.9.2.2)
19
22
  rest-client (1.6.7)
20
23
  mime-types (>= 1.16)
21
24
  rspec (2.4.0)
@@ -26,6 +29,7 @@ GEM
26
29
  rspec-expectations (2.4.0)
27
30
  diff-lcs (~> 1.1.2)
28
31
  rspec-mocks (2.4.0)
32
+ tins (0.5.5)
29
33
 
30
34
  PLATFORMS
31
35
  ruby
@@ -34,7 +38,9 @@ DEPENDENCIES
34
38
  actionpack (~> 2.3.2)
35
39
  activeresource (~> 2.3.2)
36
40
  activesupport (~> 2.3.2)
41
+ amatch
37
42
  api-auth!
38
43
  curb (~> 0.8.1)
44
+ rake
39
45
  rest-client (~> 1.6.0)
40
46
  rspec (~> 2.4.0)
data/README.md CHANGED
@@ -36,7 +36,8 @@ SHA1 HMAC, using the client's private secret key.
36
36
  request headers and the client's secret key, which is known to only
37
37
  the client and the server but can be looked up on the server using the client's
38
38
  access id that was attached in the header. The access id can be any integer or
39
- string that uniquely identifies the client.
39
+ string that uniquely identifies the client. The signed request expires after 15
40
+ minutes in order to avoid replay attacks.
40
41
 
41
42
 
42
43
  ## References ##
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.0.1
data/api_auth.gemspec CHANGED
@@ -5,11 +5,13 @@ Gem::Specification.new do |s|
5
5
  s.name = %q{api-auth}
6
6
  s.summary = %q{Simple HMAC authentication for your APIs}
7
7
  s.description = %q{Full HMAC auth implementation for use in your gems and Rails apps.}
8
- s.homepage = %q{http://github.com/geminisbs/api-auth}
8
+ s.homepage = %q{https://github.com/mgomes/api_auth}
9
9
  s.version = File.read(File.join(File.dirname(__FILE__), 'VERSION'))
10
10
  s.authors = ["Mauricio Gomes"]
11
11
  s.email = "mauricio@edge14.com"
12
12
 
13
+ s.add_development_dependency "rake"
14
+ s.add_development_dependency "amatch"
13
15
  s.add_development_dependency "rspec", "~> 2.4.0"
14
16
  s.add_development_dependency "actionpack", "~> 2.3.2"
15
17
  s.add_development_dependency "activesupport", "~> 2.3.2"
data/lib/api_auth/base.rb CHANGED
@@ -1,81 +1,97 @@
1
1
  # api-auth is Ruby gem designed to be used both in your client and server
2
- # HTTP-based applications. It implements the same authentication methods (HMAC)
2
+ # HTTP-based applications. It implements the same authentication methods (HMAC)
3
3
  # used by Amazon Web Services.
4
4
 
5
- # The gem will sign your requests on the client side and authenticate that
6
- # signature on the server side. If your server resources are implemented as a
7
- # Rails ActiveResource, it will integrate with that. It will even generate the
5
+ # The gem will sign your requests on the client side and authenticate that
6
+ # signature on the server side. If your server resources are implemented as a
7
+ # Rails ActiveResource, it will integrate with that. It will even generate the
8
8
  # secret keys necessary for your clients to sign their requests.
9
9
  module ApiAuth
10
-
10
+
11
11
  class << self
12
-
12
+
13
13
  include Helpers
14
-
14
+
15
15
  # Signs an HTTP request using the client's access id and secret key.
16
16
  # Returns the HTTP request object with the modified headers.
17
17
  #
18
- # request: The request can be a Net::HTTP, ActionController::Request,
18
+ # request: The request can be a Net::HTTP, ActionController::Request,
19
19
  # Curb (Curl::Easy) or a RestClient object.
20
20
  #
21
21
  # access_id: The public unique identifier for the client
22
22
  #
23
- # secret_key: assigned secret key that is known to both parties
23
+ # secret_key: assigned secret key that is known to both parties
24
24
  def sign!(request, access_id, secret_key)
25
25
  headers = Headers.new(request)
26
+ headers.calculate_md5
27
+ headers.set_date
26
28
  headers.sign_header auth_header(request, access_id, secret_key)
27
29
  end
28
-
30
+
29
31
  # Determines if the request is authentic given the request and the client's
30
32
  # secret key. Returns true if the request is authentic and false otherwise.
31
33
  def authentic?(request, secret_key)
32
34
  return false if secret_key.nil?
33
-
34
- headers = Headers.new(request)
35
- if match_data = parse_auth_header(headers.authorization_header)
36
- hmac = match_data[2]
37
- return hmac == hmac_signature(request, secret_key)
38
- end
39
-
40
- false
35
+
36
+ return !md5_mismatch?(request) && signatures_match?(request, secret_key) && !request_too_old?(request)
41
37
  end
42
-
38
+
43
39
  # Returns the access id from the request's authorization header
44
40
  def access_id(request)
45
41
  headers = Headers.new(request)
46
42
  if match_data = parse_auth_header(headers.authorization_header)
47
43
  return match_data[1]
48
44
  end
49
-
45
+
50
46
  nil
51
47
  end
52
-
48
+
53
49
  # Generates a Base64 encoded, randomized secret key
54
50
  #
55
- # Store this key along with the access key that will be used for
51
+ # Store this key along with the access key that will be used for
56
52
  # authenticating the client
57
53
  def generate_secret_key
58
54
  random_bytes = OpenSSL::Random.random_bytes(512)
59
55
  b64_encode(Digest::SHA2.new(512).digest(random_bytes))
60
56
  end
61
-
57
+
62
58
  private
63
-
59
+
60
+ def request_too_old?(request)
61
+ headers = Headers.new(request)
62
+ # 900 seconds is 15 minutes
63
+ Time.parse(headers.timestamp).utc < (Time.current.utc - 900)
64
+ end
65
+
66
+ def md5_mismatch?(request)
67
+ headers = Headers.new(request)
68
+ headers.md5_mismatch?
69
+ end
70
+
71
+ def signatures_match?(request, secret_key)
72
+ headers = Headers.new(request)
73
+ if match_data = parse_auth_header(headers.authorization_header)
74
+ hmac = match_data[2]
75
+ return hmac == hmac_signature(request, secret_key)
76
+ end
77
+ false
78
+ end
79
+
64
80
  def hmac_signature(request, secret_key)
65
81
  headers = Headers.new(request)
66
82
  canonical_string = headers.canonical_string
67
83
  digest = OpenSSL::Digest::Digest.new('sha1')
68
84
  b64_encode(OpenSSL::HMAC.digest(digest, secret_key, canonical_string))
69
85
  end
70
-
86
+
71
87
  def auth_header(request, access_id, secret_key)
72
- "APIAuth #{access_id}:#{hmac_signature(request, secret_key)}"
88
+ "APIAuth #{access_id}:#{hmac_signature(request, secret_key)}"
73
89
  end
74
-
90
+
75
91
  def parse_auth_header(auth_header)
76
92
  Regexp.new("APIAuth ([^:]+):(.+)$").match(auth_header)
77
93
  end
78
-
94
+
79
95
  end # class methods
80
-
96
+
81
97
  end # ApiAuth
@@ -1,13 +1,13 @@
1
1
  module ApiAuth
2
-
2
+
3
3
  # Builds the canonical string given a request object.
4
4
  class Headers
5
-
5
+
6
6
  include RequestDrivers
7
-
7
+
8
8
  def initialize(request)
9
9
  @original_request = request
10
-
10
+
11
11
  case request.class.to_s
12
12
  when /Net::HTTP/
13
13
  @request = NetHttpRequest.new(request)
@@ -31,6 +31,11 @@ module ApiAuth
31
31
  true
32
32
  end
33
33
 
34
+ # Returns the request timestamp
35
+ def timestamp
36
+ @request.timestamp
37
+ end
38
+
34
39
  # Returns the canonical string computed from the request's headers
35
40
  def canonical_string
36
41
  [ @request.content_type,
@@ -39,12 +44,28 @@ module ApiAuth
39
44
  @request.timestamp
40
45
  ].join(",")
41
46
  end
42
-
47
+
43
48
  # Returns the authorization header from the request's headers
44
49
  def authorization_header
45
50
  @request.authorization_header
46
51
  end
47
-
52
+
53
+ def set_date
54
+ @request.set_date if @request.timestamp.blank?
55
+ end
56
+
57
+ def calculate_md5
58
+ @request.populate_content_md5 if @request.content_md5.blank?
59
+ end
60
+
61
+ def md5_mismatch?
62
+ if @request.content_md5.blank?
63
+ false
64
+ else
65
+ @request.md5_mismatch?
66
+ end
67
+ end
68
+
48
69
  # Sets the request's authorization header with the passed in value.
49
70
  # The header should be the ApiAuth HMAC signature.
50
71
  #
@@ -53,7 +74,7 @@ module ApiAuth
53
74
  def sign_header(header)
54
75
  @request.set_auth_header header
55
76
  end
56
-
77
+
57
78
  end
58
-
79
+
59
80
  end
@@ -1,9 +1,9 @@
1
1
  module ApiAuth
2
-
2
+
3
3
  module RequestDrivers # :nodoc:
4
-
4
+
5
5
  class ActionControllerRequest # :nodoc:
6
-
6
+
7
7
  include ApiAuth::Helpers
8
8
 
9
9
  def initialize(request)
@@ -11,13 +11,36 @@ module ApiAuth
11
11
  @headers = fetch_headers
12
12
  true
13
13
  end
14
-
14
+
15
15
  def set_auth_header(header)
16
16
  @request.env["Authorization"] = header
17
17
  @headers = fetch_headers
18
18
  @request
19
19
  end
20
-
20
+
21
+ def calculated_md5
22
+ if @request.body
23
+ body = @request.body.read
24
+ else
25
+ body = ''
26
+ end
27
+ Digest::MD5.base64digest(body)
28
+ end
29
+
30
+ def populate_content_md5
31
+ if @request.put? || @request.post?
32
+ @request.env["Content-MD5"] = calculated_md5
33
+ end
34
+ end
35
+
36
+ def md5_mismatch?
37
+ if @request.put? || @request.post?
38
+ calculated_md5 != content_md5
39
+ else
40
+ false
41
+ end
42
+ end
43
+
21
44
  def fetch_headers
22
45
  capitalize_keys @request.env
23
46
  end
@@ -28,7 +51,7 @@ module ApiAuth
28
51
  end
29
52
 
30
53
  def content_md5
31
- value = find_header(%w(CONTENT-MD5 CONTENT_MD5))
54
+ value = find_header(%w(CONTENT-MD5 CONTENT_MD5 HTTP_CONTENT_MD5))
32
55
  value.nil? ? "" : value
33
56
  end
34
57
 
@@ -36,13 +59,13 @@ module ApiAuth
36
59
  @request.request_uri
37
60
  end
38
61
 
62
+ def set_date
63
+ @request.env['DATE'] = Time.now.utc.httpdate
64
+ end
65
+
39
66
  def timestamp
40
67
  value = find_header(%w(DATE HTTP_DATE))
41
- if value.nil?
42
- value = Time.now.utc.httpdate
43
- @request.env['DATE'] = value
44
- end
45
- value
68
+ value.nil? ? "" : value
46
69
  end
47
70
 
48
71
  def authorization_header
@@ -56,7 +79,7 @@ module ApiAuth
56
79
  end
57
80
 
58
81
  end
59
-
82
+
60
83
  end
61
-
62
- end
84
+
85
+ end
@@ -1,9 +1,9 @@
1
1
  module ApiAuth
2
-
2
+
3
3
  module RequestDrivers # :nodoc:
4
-
4
+
5
5
  class CurbRequest # :nodoc:
6
-
6
+
7
7
  include ApiAuth::Helpers
8
8
 
9
9
  def initialize(request)
@@ -11,13 +11,21 @@ module ApiAuth
11
11
  @headers = fetch_headers
12
12
  true
13
13
  end
14
-
14
+
15
15
  def set_auth_header(header)
16
16
  @request.headers.merge!({ "Authorization" => header })
17
17
  @headers = fetch_headers
18
18
  @request
19
19
  end
20
-
20
+
21
+ def populate_content_md5
22
+ nil #doesn't appear to be possible
23
+ end
24
+
25
+ def md5_mismatch?
26
+ false
27
+ end
28
+
21
29
  def fetch_headers
22
30
  capitalize_keys @request.headers
23
31
  end
@@ -36,13 +44,13 @@ module ApiAuth
36
44
  @request.url
37
45
  end
38
46
 
47
+ def set_date
48
+ @request.headers.merge!({ "DATE" => Time.now.utc.httpdate })
49
+ end
50
+
39
51
  def timestamp
40
52
  value = find_header(%w(DATE HTTP_DATE))
41
- if value.nil?
42
- value = Time.now.utc.httpdate
43
- @request.headers.merge!({ "DATE" => value })
44
- end
45
- value
53
+ value.nil? ? "" : value
46
54
  end
47
55
 
48
56
  def authorization_header
@@ -56,7 +64,7 @@ module ApiAuth
56
64
  end
57
65
 
58
66
  end
59
-
67
+
60
68
  end
61
-
62
- end
69
+
70
+ end
@@ -1,7 +1,7 @@
1
1
  module ApiAuth
2
-
2
+
3
3
  module RequestDrivers # :nodoc:
4
-
4
+
5
5
  class NetHttpRequest # :nodoc:
6
6
 
7
7
  def initialize(request)
@@ -9,13 +9,31 @@ module ApiAuth
9
9
  @headers = fetch_headers
10
10
  true
11
11
  end
12
-
12
+
13
13
  def set_auth_header(header)
14
14
  @request["Authorization"] = header
15
15
  @headers = fetch_headers
16
16
  @request
17
17
  end
18
-
18
+
19
+ def calculated_md5
20
+ Digest::MD5.base64digest(@request.body || '')
21
+ end
22
+
23
+ def populate_content_md5
24
+ if @request.class::REQUEST_HAS_BODY
25
+ @request["Content-MD5"] = calculated_md5
26
+ end
27
+ end
28
+
29
+ def md5_mismatch?
30
+ if @request.class::REQUEST_HAS_BODY
31
+ calculated_md5 != content_md5
32
+ else
33
+ false
34
+ end
35
+ end
36
+
19
37
  def fetch_headers
20
38
  @request
21
39
  end
@@ -34,13 +52,13 @@ module ApiAuth
34
52
  @request.path
35
53
  end
36
54
 
55
+ def set_date
56
+ @request["DATE"] = Time.now.utc.httpdate
57
+ end
58
+
37
59
  def timestamp
38
60
  value = find_header(%w(DATE HTTP_DATE))
39
- if value.nil?
40
- value = Time.now.utc.httpdate
41
- @request["DATE"] = value
42
- end
43
- value
61
+ value.nil? ? "" : value
44
62
  end
45
63
 
46
64
  def authorization_header
@@ -54,7 +72,7 @@ module ApiAuth
54
72
  end
55
73
 
56
74
  end
57
-
75
+
58
76
  end
59
-
60
- end
77
+
78
+ end
@@ -1,9 +1,9 @@
1
1
  module ApiAuth
2
-
2
+
3
3
  module RequestDrivers # :nodoc:
4
-
4
+
5
5
  class RestClientRequest # :nodoc:
6
-
6
+
7
7
  include ApiAuth::Helpers
8
8
 
9
9
  def initialize(request)
@@ -11,13 +11,36 @@ module ApiAuth
11
11
  @headers = fetch_headers
12
12
  true
13
13
  end
14
-
14
+
15
15
  def set_auth_header(header)
16
16
  @request.headers.merge!({ "Authorization" => header })
17
17
  @headers = fetch_headers
18
18
  @request
19
19
  end
20
-
20
+
21
+ def calculated_md5
22
+ if @request.payload
23
+ body = @request.payload.read
24
+ else
25
+ body = ''
26
+ end
27
+ Digest::MD5.base64digest(body)
28
+ end
29
+
30
+ def populate_content_md5
31
+ if [:post, :put].include?(@request.method)
32
+ @request.headers["Content-MD5"] = calculated_md5
33
+ end
34
+ end
35
+
36
+ def md5_mismatch?
37
+ if [:post, :put].include?(@request.method)
38
+ calculated_md5 != content_md5
39
+ else
40
+ false
41
+ end
42
+ end
43
+
21
44
  def fetch_headers
22
45
  capitalize_keys @request.headers
23
46
  end
@@ -36,13 +59,13 @@ module ApiAuth
36
59
  @request.url
37
60
  end
38
61
 
62
+ def set_date
63
+ @request.headers.merge!({ "DATE" => Time.now.utc.httpdate })
64
+ end
65
+
39
66
  def timestamp
40
67
  value = find_header(%w(DATE HTTP_DATE))
41
- if value.nil?
42
- value = Time.now.utc.httpdate
43
- @request.headers.merge!({ "DATE" => value })
44
- end
45
- value
68
+ value.nil? ? "" : value
46
69
  end
47
70
 
48
71
  def authorization_header
@@ -56,7 +79,7 @@ module ApiAuth
56
79
  end
57
80
 
58
81
  end
59
-
82
+
60
83
  end
61
-
62
- end
84
+
85
+ end
@@ -1,52 +1,77 @@
1
1
  require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
2
 
3
3
  describe "ApiAuth" do
4
-
4
+
5
5
  describe "generating secret keys" do
6
-
6
+
7
7
  it "should generate secret keys" do
8
8
  ApiAuth.generate_secret_key
9
9
  end
10
-
10
+
11
11
  it "should generate secret keys that are 89 characters" do
12
12
  ApiAuth.generate_secret_key.size.should be(89)
13
13
  end
14
-
14
+
15
15
  it "should generate keys that have a Hamming Distance of at least 65" do
16
16
  key1 = ApiAuth.generate_secret_key
17
17
  key2 = ApiAuth.generate_secret_key
18
18
  Amatch::Hamming.new(key1).match(key2).should be > 65
19
19
  end
20
-
20
+
21
21
  end
22
-
22
+
23
23
  describe "signing requests" do
24
-
24
+
25
25
  def hmac(secret_key, request)
26
26
  canonical_string = ApiAuth::Headers.new(request).canonical_string
27
27
  digest = OpenSSL::Digest::Digest.new('sha1')
28
28
  ApiAuth.b64_encode(OpenSSL::HMAC.digest(digest, secret_key, canonical_string))
29
29
  end
30
-
30
+
31
31
  before(:all) do
32
32
  @access_id = "1044"
33
33
  @secret_key = ApiAuth.generate_secret_key
34
34
  end
35
-
35
+
36
36
  describe "with Net::HTTP" do
37
-
37
+
38
38
  before(:each) do
39
39
  @request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
40
40
  'content-type' => 'text/plain',
41
- 'content-md5' => 'e59ff97941044f85df5297e1c302d260',
42
- 'date' => "Mon, 23 Jan 1984 03:29:56 GMT")
41
+ 'content-md5' => '1B2M2Y8AsgTpgAmY7PhCfg==',
42
+ 'date' => Time.now.utc.httpdate)
43
43
  @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
44
44
  end
45
-
45
+
46
46
  it "should return a Net::HTTP object after signing it" do
47
47
  ApiAuth.sign!(@request, @access_id, @secret_key).class.to_s.should match("Net::HTTP")
48
48
  end
49
-
49
+
50
+ describe "md5 header" do
51
+ context "not already provided" do
52
+ it "should calculate for empty string" do
53
+ request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
54
+ 'content-type' => 'text/plain',
55
+ 'date' => "Mon, 23 Jan 1984 03:29:56 GMT")
56
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
57
+ signed_request['Content-MD5'].should == Digest::MD5.base64digest('')
58
+ end
59
+
60
+ it "should calculate for real content" do
61
+ request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
62
+ 'content-type' => 'text/plain',
63
+ 'date' => "Mon, 23 Jan 1984 03:29:56 GMT")
64
+ request.body = "hello\nworld"
65
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
66
+ signed_request['Content-MD5'].should == Digest::MD5.base64digest("hello\nworld")
67
+ end
68
+ end
69
+
70
+ it "should leave the content-md5 alone if provided" do
71
+ @signed_request['Content-MD5'].should == '1B2M2Y8AsgTpgAmY7PhCfg=='
72
+ end
73
+ end
74
+
50
75
  it "should sign the request" do
51
76
  @signed_request['Authorization'].should == "APIAuth 1044:#{hmac(@secret_key, @request)}"
52
77
  end
@@ -54,33 +79,78 @@ describe "ApiAuth" do
54
79
  it "should authenticate a valid request" do
55
80
  ApiAuth.authentic?(@signed_request, @secret_key).should be_true
56
81
  end
57
-
82
+
58
83
  it "should NOT authenticate a non-valid request" do
59
84
  ApiAuth.authentic?(@signed_request, @secret_key+'j').should be_false
60
85
  end
86
+
87
+ it "should NOT authenticate a mismatched content-md5 when body has changed" do
88
+ request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
89
+ 'content-type' => 'text/plain',
90
+ 'date' => "Mon, 23 Jan 1984 03:29:56 GMT")
91
+ request.body = "hello\nworld"
92
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
93
+ signed_request.body = "goodbye"
94
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
95
+ end
96
+
97
+ it "should NOT authenticate an expired request" do
98
+ @request['Date'] = 16.minutes.ago.utc.httpdate
99
+ signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
100
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
101
+ end
61
102
 
62
103
  it "should retrieve the access_id" do
63
104
  ApiAuth.access_id(@signed_request).should == "1044"
64
105
  end
65
-
106
+
66
107
  end
67
-
108
+
68
109
  describe "with RestClient" do
69
-
110
+
70
111
  before(:each) do
71
- headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
112
+ headers = { 'Content-MD5' => "1B2M2Y8AsgTpgAmY7PhCfg==",
72
113
  'Content-Type' => "text/plain",
73
- 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
114
+ 'Date' => Time.now.utc.httpdate }
74
115
  @request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
75
116
  :headers => headers,
76
117
  :method => :put)
77
118
  @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
78
119
  end
79
-
120
+
80
121
  it "should return a RestClient object after signing it" do
81
122
  ApiAuth.sign!(@request, @access_id, @secret_key).class.to_s.should match("RestClient")
82
123
  end
83
-
124
+
125
+ describe "md5 header" do
126
+ context "not already provided" do
127
+ it "should calculate for empty string" do
128
+ headers = { 'Content-Type' => "text/plain",
129
+ 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
130
+ request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
131
+ :headers => headers,
132
+ :method => :put)
133
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
134
+ signed_request.headers['Content-MD5'].should == Digest::MD5.base64digest('')
135
+ end
136
+
137
+ it "should calculate for real content" do
138
+ headers = { 'Content-Type' => "text/plain",
139
+ 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
140
+ request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
141
+ :headers => headers,
142
+ :method => :put,
143
+ :payload => "hellow\nworld")
144
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
145
+ signed_request.headers['Content-MD5'].should == Digest::MD5.base64digest("hellow\nworld")
146
+ end
147
+ end
148
+
149
+ it "should leave the content-md5 alone if provided" do
150
+ @signed_request.headers['Content-MD5'].should == "1B2M2Y8AsgTpgAmY7PhCfg=="
151
+ end
152
+ end
153
+
84
154
  it "should sign the request" do
85
155
  @signed_request.headers['Authorization'].should == "APIAuth 1044:#{hmac(@secret_key, @request)}"
86
156
  end
@@ -88,33 +158,67 @@ describe "ApiAuth" do
88
158
  it "should authenticate a valid request" do
89
159
  ApiAuth.authentic?(@signed_request, @secret_key).should be_true
90
160
  end
91
-
161
+
92
162
  it "should NOT authenticate a non-valid request" do
93
163
  ApiAuth.authentic?(@signed_request, @secret_key+'j').should be_false
94
164
  end
165
+
166
+ it "should NOT authenticate a mismatched content-md5 when body has changed" do
167
+ headers = { 'Content-Type' => "text/plain",
168
+ 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
169
+ request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
170
+ :headers => headers,
171
+ :method => :put,
172
+ :payload => "hello\nworld")
173
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
174
+ signed_request.instance_variable_set("@payload", RestClient::Payload.generate('goodbye'))
175
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
176
+ end
177
+
178
+ it "should NOT authenticate an expired request" do
179
+ @request.headers['Date'] = 16.minutes.ago.utc.httpdate
180
+ signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
181
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
182
+ end
95
183
 
96
184
  it "should retrieve the access_id" do
97
185
  ApiAuth.access_id(@signed_request).should == "1044"
98
186
  end
99
-
187
+
100
188
  end
101
-
189
+
102
190
  describe "with Curb" do
103
-
191
+
104
192
  before(:each) do
105
193
  headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
106
194
  'Content-Type' => "text/plain",
107
- 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
195
+ 'Date' => Time.now.utc.httpdate }
108
196
  @request = Curl::Easy.new("/resource.xml?foo=bar&bar=foo") do |curl|
109
197
  curl.headers = headers
110
198
  end
111
199
  @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
112
200
  end
113
-
201
+
114
202
  it "should return a Curl::Easy object after signing it" do
115
203
  ApiAuth.sign!(@request, @access_id, @secret_key).class.to_s.should match("Curl::Easy")
116
204
  end
117
-
205
+
206
+ describe "md5 header" do
207
+ it "should not calculate and add the content-md5 header if not provided" do
208
+ headers = { 'Content-Type' => "text/plain",
209
+ 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
210
+ request = Curl::Easy.new("/resource.xml?foo=bar&bar=foo") do |curl|
211
+ curl.headers = headers
212
+ end
213
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
214
+ signed_request.headers['Content-MD5'].should == nil
215
+ end
216
+
217
+ it "should leave the content-md5 alone if provided" do
218
+ @signed_request.headers['Content-MD5'].should == "e59ff97941044f85df5297e1c302d260"
219
+ end
220
+ end
221
+
118
222
  it "should sign the request" do
119
223
  @signed_request.headers['Authorization'].should == "APIAuth 1044:#{hmac(@secret_key, @request)}"
120
224
  end
@@ -122,17 +226,23 @@ describe "ApiAuth" do
122
226
  it "should authenticate a valid request" do
123
227
  ApiAuth.authentic?(@signed_request, @secret_key).should be_true
124
228
  end
125
-
229
+
126
230
  it "should NOT authenticate a non-valid request" do
127
231
  ApiAuth.authentic?(@signed_request, @secret_key+'j').should be_false
128
232
  end
233
+
234
+ it "should NOT authenticate an expired request" do
235
+ @request.headers['Date'] = 16.minutes.ago.utc.httpdate
236
+ signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
237
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
238
+ end
129
239
 
130
240
  it "should retrieve the access_id" do
131
241
  ApiAuth.access_id(@signed_request).should == "1044"
132
242
  end
133
-
243
+
134
244
  end
135
-
245
+
136
246
  describe "with ActionController" do
137
247
 
138
248
  before(:each) do
@@ -140,9 +250,9 @@ describe "ApiAuth" do
140
250
  'PATH_INFO' => '/resource.xml',
141
251
  'QUERY_STRING' => 'foo=bar&bar=foo',
142
252
  'REQUEST_METHOD' => 'PUT',
143
- 'CONTENT_MD5' => 'e59ff97941044f85df5297e1c302d260',
253
+ 'CONTENT_MD5' => '1B2M2Y8AsgTpgAmY7PhCfg==',
144
254
  'CONTENT_TYPE' => 'text/plain',
145
- 'HTTP_DATE' => 'Mon, 23 Jan 1984 03:29:56 GMT')
255
+ 'HTTP_DATE' => Time.now.utc.httpdate)
146
256
  @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
147
257
  end
148
258
 
@@ -150,6 +260,38 @@ describe "ApiAuth" do
150
260
  ApiAuth.sign!(@request, @access_id, @secret_key).class.to_s.should match("ActionController::Request")
151
261
  end
152
262
 
263
+ describe "md5 header" do
264
+ context "not already provided" do
265
+ it "should calculate for empty string" do
266
+ request = ActionController::Request.new(
267
+ 'PATH_INFO' => '/resource.xml',
268
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
269
+ 'REQUEST_METHOD' => 'PUT',
270
+ 'CONTENT_TYPE' => 'text/plain',
271
+ 'HTTP_DATE' => 'Mon, 23 Jan 1984 03:29:56 GMT')
272
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
273
+ signed_request.env['Content-MD5'].should == Digest::MD5.base64digest('')
274
+ end
275
+
276
+ it "should calculate for real content" do
277
+ request = ActionController::Request.new(
278
+ 'PATH_INFO' => '/resource.xml',
279
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
280
+ 'REQUEST_METHOD' => 'PUT',
281
+ 'CONTENT_TYPE' => 'text/plain',
282
+ 'HTTP_DATE' => 'Mon, 23 Jan 1984 03:29:56 GMT',
283
+ 'rack.input' => StringIO.new("hello\nworld"))
284
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
285
+ signed_request.env['Content-MD5'].should == Digest::MD5.base64digest("hello\nworld")
286
+ end
287
+
288
+ end
289
+
290
+ it "should leave the content-md5 alone if provided" do
291
+ @signed_request.env['CONTENT_MD5'].should == '1B2M2Y8AsgTpgAmY7PhCfg=='
292
+ end
293
+ end
294
+
153
295
  it "should sign the request" do
154
296
  @signed_request.env['Authorization'].should == "APIAuth 1044:#{hmac(@secret_key, @request)}"
155
297
  end
@@ -162,6 +304,25 @@ describe "ApiAuth" do
162
304
  ApiAuth.authentic?(@signed_request, @secret_key+'j').should be_false
163
305
  end
164
306
 
307
+ it "should NOT authenticate a mismatched content-md5 when body has changed" do
308
+ request = ActionController::Request.new(
309
+ 'PATH_INFO' => '/resource.xml',
310
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
311
+ 'REQUEST_METHOD' => 'PUT',
312
+ 'CONTENT_TYPE' => 'text/plain',
313
+ 'HTTP_DATE' => 'Mon, 23 Jan 1984 03:29:56 GMT',
314
+ 'rack.input' => StringIO.new("hello\nworld"))
315
+ signed_request = ApiAuth.sign!(request, @access_id, @secret_key)
316
+ signed_request.instance_variable_get("@env")["rack.input"] = StringIO.new("goodbye")
317
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
318
+ end
319
+
320
+ it "should NOT authenticate an expired request" do
321
+ @request.env['HTTP_DATE'] = 16.minutes.ago.utc.httpdate
322
+ signed_request = ApiAuth.sign!(@request, @access_id, @secret_key)
323
+ ApiAuth.authentic?(signed_request, @secret_key).should be_false
324
+ end
325
+
165
326
  it "should retrieve the access_id" do
166
327
  ApiAuth.access_id(@signed_request).should == "1044"
167
328
  end
@@ -169,5 +330,5 @@ describe "ApiAuth" do
169
330
  end
170
331
 
171
332
  end
172
-
333
+
173
334
  end
data/spec/headers_spec.rb CHANGED
@@ -1,73 +1,100 @@
1
1
  require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
2
 
3
3
  describe "ApiAuth::Headers" do
4
-
4
+
5
5
  CANONICAL_STRING = "text/plain,e59ff97941044f85df5297e1c302d260,/resource.xml?foo=bar&bar=foo,Mon, 23 Jan 1984 03:29:56 GMT"
6
6
 
7
7
  describe "with Net::HTTP" do
8
-
8
+
9
9
  before(:each) do
10
- @request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
11
- 'content-type' => 'text/plain',
10
+ @request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
11
+ 'content-type' => 'text/plain',
12
12
  'content-md5' => 'e59ff97941044f85df5297e1c302d260',
13
13
  'date' => "Mon, 23 Jan 1984 03:29:56 GMT")
14
14
  @headers = ApiAuth::Headers.new(@request)
15
15
  end
16
-
16
+
17
17
  it "should generate the proper canonical string" do
18
18
  @headers.canonical_string.should == CANONICAL_STRING
19
19
  end
20
-
20
+
21
21
  it "should set the authorization header" do
22
22
  @headers.sign_header("alpha")
23
23
  @headers.authorization_header.should == "alpha"
24
24
  end
25
-
25
+
26
26
  it "should set the DATE header if one is not already present" do
27
- @request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
28
- 'content-type' => 'text/plain',
27
+ @request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
28
+ 'content-type' => 'text/plain',
29
29
  'content-md5' => 'e59ff97941044f85df5297e1c302d260')
30
30
  ApiAuth.sign!(@request, "some access id", "some secret key")
31
31
  @request['DATE'].should_not be_nil
32
32
  end
33
-
33
+
34
+ it "should not set the DATE header just by asking for the canonical_string" do
35
+ request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
36
+ 'content-type' => 'text/plain',
37
+ 'content-md5' => 'e59ff97941044f85df5297e1c302d260')
38
+ headers = ApiAuth::Headers.new(request)
39
+ headers.canonical_string
40
+ request['DATE'].should be_nil
41
+ end
42
+
43
+ context "md5_mismatch?" do
44
+ it "is false if no md5 header is present" do
45
+ request = Net::HTTP::Put.new("/resource.xml?foo=bar&bar=foo",
46
+ 'content-type' => 'text/plain')
47
+ headers = ApiAuth::Headers.new(request)
48
+ headers.md5_mismatch?.should be_false
49
+ end
50
+ end
34
51
  end
35
-
52
+
36
53
  describe "with RestClient" do
37
-
54
+
38
55
  before(:each) do
39
56
  headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
40
57
  'Content-Type' => "text/plain",
41
58
  'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" }
42
- @request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
59
+ @request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
43
60
  :headers => headers,
44
61
  :method => :put)
45
62
  @headers = ApiAuth::Headers.new(@request)
46
63
  end
47
-
64
+
48
65
  it "should generate the proper canonical string" do
49
66
  @headers.canonical_string.should == CANONICAL_STRING
50
67
  end
51
-
68
+
52
69
  it "should set the authorization header" do
53
70
  @headers.sign_header("alpha")
54
71
  @headers.authorization_header.should == "alpha"
55
72
  end
56
-
73
+
57
74
  it "should set the DATE header if one is not already present" do
58
75
  headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
59
76
  'Content-Type' => "text/plain" }
60
- @request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
77
+ @request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
61
78
  :headers => headers,
62
79
  :method => :put)
63
80
  ApiAuth.sign!(@request, "some access id", "some secret key")
64
81
  @request.headers['DATE'].should_not be_nil
65
82
  end
66
-
83
+
84
+ it "should not set the DATE header just by asking for the canonical_string" do
85
+ headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
86
+ 'Content-Type' => "text/plain" }
87
+ request = RestClient::Request.new(:url => "/resource.xml?foo=bar&bar=foo",
88
+ :headers => headers,
89
+ :method => :put)
90
+ headers = ApiAuth::Headers.new(request)
91
+ headers.canonical_string
92
+ request.headers['DATE'].should be_nil
93
+ end
67
94
  end
68
-
95
+
69
96
  describe "with Curb" do
70
-
97
+
71
98
  before(:each) do
72
99
  headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
73
100
  'Content-Type' => "text/plain",
@@ -77,16 +104,16 @@ describe "ApiAuth::Headers" do
77
104
  end
78
105
  @headers = ApiAuth::Headers.new(@request)
79
106
  end
80
-
107
+
81
108
  it "should generate the proper canonical string" do
82
109
  @headers.canonical_string.should == CANONICAL_STRING
83
110
  end
84
-
111
+
85
112
  it "should set the authorization header" do
86
113
  @headers.sign_header("alpha")
87
114
  @headers.authorization_header.should == "alpha"
88
115
  end
89
-
116
+
90
117
  it "should set the DATE header if one is not already present" do
91
118
  headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
92
119
  'Content-Type' => "text/plain" }
@@ -96,9 +123,19 @@ describe "ApiAuth::Headers" do
96
123
  ApiAuth.sign!(@request, "some access id", "some secret key")
97
124
  @request.headers['DATE'].should_not be_nil
98
125
  end
99
-
126
+
127
+ it "should not set the DATE header just by asking for the canonical_string" do
128
+ headers = { 'Content-MD5' => "e59ff97941044f85df5297e1c302d260",
129
+ 'Content-Type' => "text/plain" }
130
+ request = Curl::Easy.new("/resource.xml?foo=bar&bar=foo") do |curl|
131
+ curl.headers = headers
132
+ end
133
+ headers = ApiAuth::Headers.new(request)
134
+ headers.canonical_string
135
+ request.headers['DATE'].should be_nil
136
+ end
100
137
  end
101
-
138
+
102
139
  describe "with ActionController" do
103
140
 
104
141
  before(:each) do
@@ -132,6 +169,17 @@ describe "ApiAuth::Headers" do
132
169
  @request.headers['DATE'].should_not be_nil
133
170
  end
134
171
 
172
+ it "should not set the DATE header just by asking for the canonical_string" do
173
+ request = ActionController::Request.new(
174
+ 'PATH_INFO' => '/resource.xml',
175
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
176
+ 'REQUEST_METHOD' => 'PUT',
177
+ 'CONTENT_MD5' => 'e59ff97941044f85df5297e1c302d260',
178
+ 'CONTENT_TYPE' => 'text/plain')
179
+ headers = ApiAuth::Headers.new(request)
180
+ headers.canonical_string
181
+ request.headers['DATE'].should be_nil
182
+ end
135
183
  end
136
184
 
137
185
  end
data/spec/railtie_spec.rb CHANGED
@@ -41,13 +41,22 @@ describe "Rails integration" do
41
41
 
42
42
  it "should permit a request with properly signed headers" do
43
43
  request = ActionController::TestRequest.new
44
- request.env['DATE'] = "Mon, 23 Jan 1984 03:29:56 GMT"
44
+ request.env['DATE'] = Time.now.utc.httpdate
45
45
  request.action = 'index'
46
46
  request.path = "/index"
47
47
  ApiAuth.sign!(request, "1044", API_KEY_STORE["1044"])
48
48
  TestController.new.process(request, ActionController::TestResponse.new).code.should == "200"
49
49
  end
50
50
 
51
+ it "should forbid a request with properly signed headers but timestamp > 15 minutes" do
52
+ request = ActionController::TestRequest.new
53
+ request.env['DATE'] = "Mon, 23 Jan 1984 03:29:56 GMT"
54
+ request.action = 'index'
55
+ request.path = "/index"
56
+ ApiAuth.sign!(request, "1044", API_KEY_STORE["1044"])
57
+ TestController.new.process(request, ActionController::TestResponse.new).code.should == "401"
58
+ end
59
+
51
60
  it "should insert a DATE header in the request when one hasn't been specified" do
52
61
  request = ActionController::TestRequest.new
53
62
  request.action = 'index'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api-auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,8 +9,40 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-09-24 00:00:00.000000000 Z
12
+ date: 2012-11-30 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rake
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
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
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: amatch
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
14
46
  - !ruby/object:Gem::Dependency
15
47
  name: rspec
16
48
  requirement: !ruby/object:Gem::Requirement
@@ -142,7 +174,7 @@ files:
142
174
  - spec/railtie_spec.rb
143
175
  - spec/spec_helper.rb
144
176
  - spec/test_helper.rb
145
- homepage: http://github.com/geminisbs/api-auth
177
+ homepage: https://github.com/mgomes/api_auth
146
178
  licenses: []
147
179
  post_install_message:
148
180
  rdoc_options: []
@@ -154,12 +186,18 @@ required_ruby_version: !ruby/object:Gem::Requirement
154
186
  - - ! '>='
155
187
  - !ruby/object:Gem::Version
156
188
  version: '0'
189
+ segments:
190
+ - 0
191
+ hash: -1454681296772684747
157
192
  required_rubygems_version: !ruby/object:Gem::Requirement
158
193
  none: false
159
194
  requirements:
160
195
  - - ! '>='
161
196
  - !ruby/object:Gem::Version
162
197
  version: '0'
198
+ segments:
199
+ - 0
200
+ hash: -1454681296772684747
163
201
  requirements: []
164
202
  rubyforge_project:
165
203
  rubygems_version: 1.8.24