hawk-auth 0.0.0 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +7 -0
- data/README.md +91 -69
- data/Rakefile +8 -1
- data/hawk-auth.gemspec +2 -0
- data/lib/hawk.rb +5 -2
- data/lib/hawk/authentication_failure.rb +18 -0
- data/lib/hawk/authorization_header.rb +114 -0
- data/lib/hawk/client.rb +22 -0
- data/lib/hawk/crypto.rb +96 -0
- data/lib/hawk/server.rb +54 -0
- data/lib/hawk/version.rb +1 -1
- data/spec/authentication_header_spec.rb +33 -0
- data/spec/client_spec.rb +174 -0
- data/spec/crypto_spec.rb +197 -0
- data/spec/server_spec.rb +290 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/support/shared_examples/authorization_header.rb +154 -0
- metadata +53 -3
data/.travis.yml
ADDED
data/README.md
CHANGED
@@ -1,74 +1,7 @@
|
|
1
|
-
# Hawk
|
1
|
+
# Hawk [![Build Status](https://travis-ci.org/tent/hawk-ruby.png)](https://travis-ci.org/tent/hawk-ruby)
|
2
2
|
|
3
3
|
Ruby implementation of [Hawk HTTP authentication scheme](https://github.com/hueniverse/hawk).
|
4
4
|
|
5
|
-
**Authorization Request Header**
|
6
|
-
|
7
|
-
```
|
8
|
-
Authorization: Hawk id="{credentials id}", ts="{epoch timestamp}", nonce="{nonce}", hash="{hash}", ext="{ext}", mac="{mac}", app="{application id}", d1g="{d1g}"
|
9
|
-
```
|
10
|
-
|
11
|
-
`hash`, `ext`, `app`, and `d1g` should only be included if used in mac function.
|
12
|
-
|
13
|
-
**Authorization Response Header**
|
14
|
-
|
15
|
-
```
|
16
|
-
Server-Authorization: Hawk mac="{mac}", hash="{hash}", ext="{ext}"
|
17
|
-
```
|
18
|
-
|
19
|
-
`mac` is constructed using the same params as in the request with the exception of `hash` and `ext` which are replaced with new values.
|
20
|
-
|
21
|
-
`hash` and `ext` are both optional.
|
22
|
-
|
23
|
-
**MAC Function**
|
24
|
-
|
25
|
-
```
|
26
|
-
base-64(
|
27
|
-
hmac-{algorithm (e.g. sha-256)}(
|
28
|
-
hawk.{hawk version}.{type}
|
29
|
-
{epoch timestamp}
|
30
|
-
{nonce}
|
31
|
-
{uppercase request method}
|
32
|
-
{lowercase request path}
|
33
|
-
{lowercase request host}
|
34
|
-
{request port}
|
35
|
-
{hash (see below) or empty line}
|
36
|
-
{ext (optional)}
|
37
|
-
{application id (optional)}
|
38
|
-
{application id digest (requires application id)}
|
39
|
-
)
|
40
|
-
)
|
41
|
-
```
|
42
|
-
|
43
|
-
**Payload Hash Function**
|
44
|
-
|
45
|
-
```
|
46
|
-
base-64(
|
47
|
-
digest-{algorithm (e.g. sha-256)}(
|
48
|
-
hawk.#{hawk version}.payload
|
49
|
-
{plain content-type (e.g. application/json)}
|
50
|
-
{request payload or empty line}
|
51
|
-
)
|
52
|
-
)
|
53
|
-
```
|
54
|
-
|
55
|
-
**Bewit MAC Function**
|
56
|
-
|
57
|
-
```
|
58
|
-
base-64(
|
59
|
-
{credentials id} + \ + {expiry epoch timestamp} + \ + hmac-{algorithm (e.g. sha-256)}(
|
60
|
-
hawk.{hawk version}.bewit
|
61
|
-
{epoch timestamp}
|
62
|
-
{nonce}
|
63
|
-
{uppercase request method}
|
64
|
-
{lowercase request path}
|
65
|
-
{lowercase request host}
|
66
|
-
{request port}
|
67
|
-
{ext (optional)}
|
68
|
-
) + \ + {ext or empty}
|
69
|
-
)
|
70
|
-
```
|
71
|
-
|
72
5
|
## Installation
|
73
6
|
|
74
7
|
Add this line to your application's Gemfile:
|
@@ -85,7 +18,96 @@ Or install it yourself as:
|
|
85
18
|
|
86
19
|
## Usage
|
87
20
|
|
88
|
-
|
21
|
+
```
|
22
|
+
$ irb
|
23
|
+
> require 'hawk'
|
24
|
+
|
25
|
+
> Hawk::Client.build_authorization_header(
|
26
|
+
> :credentials => {
|
27
|
+
> :id => '123456',
|
28
|
+
> :key => '2983d45yun89q',
|
29
|
+
> :algorithm => 'sha256'
|
30
|
+
> },
|
31
|
+
> :ts => 1365898519,
|
32
|
+
> :method => 'POST',
|
33
|
+
> :path => '/somewhere/over/the/rainbow',
|
34
|
+
> :host => 'example.net',
|
35
|
+
> :port => 80,
|
36
|
+
> :payload => 'something to write about',
|
37
|
+
> :ext => 'Bazinga!',
|
38
|
+
> :nonce => 'Ygvqdz'
|
39
|
+
> )
|
40
|
+
Hawk id="123456", ts="1365898519", nonce="Ygvqdz", hash="LjRmtkSKTW0ObTUyZ7N+vjClKd//KTTdfhF1M4XCuEM=", ext="Bazinga!", mac="07uWxZfesjgR9wGYXMfCPvocryS9ct8Ir6/83zj3A5s="
|
41
|
+
|
42
|
+
> Hawk::Client.authenticate(
|
43
|
+
> %(Hawk hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", mac="0ysNmHEhwCjww5yQdbVZ1yXQ58CiRkc8O3l+rSk/TZE="),
|
44
|
+
> :credentials => {
|
45
|
+
> :id => "123456",
|
46
|
+
> :key => "2983d45yun89q",
|
47
|
+
> :algorithm => "sha256"
|
48
|
+
> },
|
49
|
+
> :ts => 1365899773,
|
50
|
+
> :method => "POST",
|
51
|
+
> :path => "/somewhere/over/the/rainbow",
|
52
|
+
> :host => "example.net",
|
53
|
+
> :port => 80,
|
54
|
+
> :payload => "something to write about",
|
55
|
+
> :content_type => "text/plain",
|
56
|
+
> :nonce => "Ygvqdz",
|
57
|
+
> )
|
58
|
+
{ :id => "123456", :key => "2983d45yun89q", :algorithm => "sha256" }
|
59
|
+
|
60
|
+
> Hawk::Client.calculate_time_offset(
|
61
|
+
> %(Hawk ts="1365741469", tsm="h/Ff6XI1euObD78ZNflapvLKXGuaw1RiLI4Q6Q5sAbM=", error="Some Error Message"),
|
62
|
+
> :credentials => { :id => "123456", :key => "2983d45yun89q", :algorithm => "sha256" }
|
63
|
+
> )
|
64
|
+
321
|
65
|
+
|
66
|
+
> credentials = { :id => "123456", :key => "2983d45yun89q", :algorithm => "sha256" }
|
67
|
+
{ :id => "123456", :key => "2983d45yun89q", :algorithm => "sha256" }
|
68
|
+
> Hawk::Server.authenticate(
|
69
|
+
> %(Hawk id="123456", ts="1365900371", nonce="Ygvqdz", hash="9LxQVpfaAgyiyNeOgD8TEKP6RnM=", mac="lv54INsJZym8wnME0nQAu5jW6BA="),
|
70
|
+
> :method => "POST",
|
71
|
+
> :path => "/somewhere/over/the/rainbow",
|
72
|
+
> :host => "example.net",
|
73
|
+
> :port => 80,
|
74
|
+
> :content_type => "text/plain",
|
75
|
+
> :credentials_lookup => lambda { |id| id == credentials[:id] ? credentials : nil },
|
76
|
+
> :nonce_lookup => lambda { |nonce| },
|
77
|
+
> :payload => "something to write about"
|
78
|
+
> )
|
79
|
+
{ :id => "123456", :key => "2983d45yun89q", :algorithm => "sha256" }
|
80
|
+
|
81
|
+
> res = Hawk::Server.authenticate(
|
82
|
+
> %(Hawk id="123456", ts="1365901299", mac="zTu3FSTmdsdSaLHd/DrpeQRkuYzcb0snYYKOmwDwP3w="),
|
83
|
+
> :method => "POST",
|
84
|
+
> :path => "/somewhere/over/the/rainbow",
|
85
|
+
> :host => "example.net",
|
86
|
+
> :port => 80,
|
87
|
+
> :content_type => "text/plain",
|
88
|
+
> :credentials_lookup => lambda { |id| id == credentials[:id] ? credentials : nil },
|
89
|
+
> :nonce_lookup => lambda { |nonce| }
|
90
|
+
> )
|
91
|
+
#<Hawk::AuthenticationFailure:0x007f95cba33168 @key=:nonce, @message="Missing nonce", @options={:credentials=>{:id=>"123456", :key=>"2983d45yun89q", :algorithm=>"sha256"}}>
|
92
|
+
> res.header
|
93
|
+
Hawk ts="1365901388", tsm="6mdH5DT66UeWlkBC9x2QD7Upt0eYnud9dB7y7xKoEoU=", error="Missing nonce"
|
94
|
+
|
95
|
+
> Hawk::Server.build_authorization_header(
|
96
|
+
> :credentials => {
|
97
|
+
> :id => "123456",
|
98
|
+
> :key => "2983d45yun89q",
|
99
|
+
> :algorithm => "sha1"
|
100
|
+
> },
|
101
|
+
> :ts => 1365900682,
|
102
|
+
> :method => "POST",
|
103
|
+
> :path => "/somewhere/over/the/rainbow",
|
104
|
+
> :host => "example.net",
|
105
|
+
> :port => 80,
|
106
|
+
> :ext => "Bazinga!",
|
107
|
+
> :nonce => "Ygvqdz"
|
108
|
+
> )
|
109
|
+
Hawk ext="Bazinga!", mac="5D0CgZEXKEdeUFYbE5HQqb7ZooI="
|
110
|
+
```
|
89
111
|
|
90
112
|
## Contributing
|
91
113
|
|
data/Rakefile
CHANGED
data/hawk-auth.gemspec
CHANGED
data/lib/hawk.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
require 'hawk/version'
|
2
2
|
|
3
3
|
module Hawk
|
4
|
-
|
5
|
-
|
4
|
+
require 'hawk/crypto'
|
5
|
+
require 'hawk/authentication_failure'
|
6
|
+
require 'hawk/authorization_header'
|
7
|
+
require 'hawk/client'
|
8
|
+
require 'hawk/server'
|
6
9
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Hawk
|
2
|
+
class AuthenticationFailure
|
3
|
+
attr_reader :key, :message
|
4
|
+
def initialize(key, message, options = {})
|
5
|
+
@key, @message, @options = key, message, options
|
6
|
+
end
|
7
|
+
|
8
|
+
def header
|
9
|
+
timestamp = Time.now.to_i
|
10
|
+
if @options[:credentials]
|
11
|
+
timestamp_mac = Crypto.ts_mac(:ts => timestamp, :credentials => @options[:credentials])
|
12
|
+
%(Hawk ts="#{timestamp}", tsm="#{timestamp_mac}", error="#{message}")
|
13
|
+
else
|
14
|
+
%(Hawk error="#{message}")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module Hawk
|
2
|
+
module AuthorizationHeader
|
3
|
+
extend self
|
4
|
+
|
5
|
+
REQUIRED_OPTIONS = [:method, :path, :host, :port].freeze
|
6
|
+
REQUIRED_CREDENTIAL_MEMBERS = [:id, :key, :algorithm].freeze
|
7
|
+
SUPPORTED_ALGORITHMS = ['sha256', 'sha1'].freeze
|
8
|
+
HEADER_PARTS = [:id, :ts, :nonce, :hash, :ext, :mac].freeze
|
9
|
+
|
10
|
+
MissingOptionError = Class.new(StandardError)
|
11
|
+
InvalidCredentialsError = Class.new(StandardError)
|
12
|
+
InvalidAlgorithmError = Class.new(StandardError)
|
13
|
+
|
14
|
+
def build(options, only=nil)
|
15
|
+
options[:ts] ||= Time.now.to_i
|
16
|
+
options[:nonce] ||= SecureRandom.hex(4)
|
17
|
+
|
18
|
+
REQUIRED_OPTIONS.each do |key|
|
19
|
+
unless options.has_key?(key)
|
20
|
+
raise MissingOptionError.new("#{key.inspect} is missing!")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
credentials = options[:credentials]
|
25
|
+
REQUIRED_CREDENTIAL_MEMBERS.each do |key|
|
26
|
+
unless credentials.has_key?(key)
|
27
|
+
raise InvalidCredentialsError.new("#{key.inspect} is missing!")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
unless SUPPORTED_ALGORITHMS.include?(credentials[:algorithm])
|
32
|
+
raise InvalidAlgorithmError.new("#{credentials[:algorithm].inspect} is not a supported algorithm! Use one of the following: #{SUPPORTED_ALGORITHMS.join(', ')}")
|
33
|
+
end
|
34
|
+
|
35
|
+
hash = Crypto.hash(options)
|
36
|
+
mac = Crypto.mac(options)
|
37
|
+
|
38
|
+
parts = {
|
39
|
+
:id => credentials[:id],
|
40
|
+
:ts => options[:ts],
|
41
|
+
:nonce => options[:nonce],
|
42
|
+
:mac => mac
|
43
|
+
}
|
44
|
+
parts[:hash] = hash if options.has_key?(:payload)
|
45
|
+
parts[:ext] = options[:ext] if options.has_key?(:ext)
|
46
|
+
|
47
|
+
"Hawk " << (only || HEADER_PARTS).inject([]) { |memo, key|
|
48
|
+
next memo unless parts.has_key?(key)
|
49
|
+
memo << %(#{key}="#{parts[key]}")
|
50
|
+
memo
|
51
|
+
}.join(', ')
|
52
|
+
end
|
53
|
+
|
54
|
+
def authenticate(header, options)
|
55
|
+
parts = parse(header)
|
56
|
+
|
57
|
+
now = Time.now.to_i
|
58
|
+
|
59
|
+
if options[:server_response]
|
60
|
+
credentials = options[:credentials]
|
61
|
+
parts.merge!(
|
62
|
+
:ts => options[:ts],
|
63
|
+
:nonce => options[:nonce]
|
64
|
+
)
|
65
|
+
else
|
66
|
+
unless options[:credentials_lookup].respond_to?(:call) && (credentials = options[:credentials_lookup].call(parts[:id]))
|
67
|
+
return AuthenticationFailure.new(:id, "Unidentified id")
|
68
|
+
end
|
69
|
+
|
70
|
+
if (now - parts[:ts].to_i > 1000) || (parts[:ts].to_i - now > 1000)
|
71
|
+
# Stale timestamp
|
72
|
+
return AuthenticationFailure.new(:ts, "Stale ts", :credentials => credentials)
|
73
|
+
end
|
74
|
+
|
75
|
+
unless parts[:nonce]
|
76
|
+
return AuthenticationFailure.new(:nonce, "Missing nonce")
|
77
|
+
end
|
78
|
+
|
79
|
+
if options[:nonce_lookup].respond_to?(:call) && options[:nonce_lookup].call(parts[:nonce])
|
80
|
+
# Replay
|
81
|
+
return AuthenticationFailure.new(:nonce, "Invalid nonce")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
expected_mac = Crypto.mac(options.merge(
|
86
|
+
:credentials => credentials,
|
87
|
+
:ts => parts[:ts],
|
88
|
+
:nonce => parts[:nonce],
|
89
|
+
:ext => parts[:ext]
|
90
|
+
))
|
91
|
+
unless expected_mac == parts[:mac]
|
92
|
+
return AuthenticationFailure.new(:mac, "Invalid mac")
|
93
|
+
end
|
94
|
+
|
95
|
+
expected_hash = parts[:hash] ? Crypto.hash(options.merge(:credentials => credentials)) : nil
|
96
|
+
if expected_hash && expected_hash != parts[:hash]
|
97
|
+
return AuthenticationFailure.new(:hash, "Invalid hash")
|
98
|
+
end
|
99
|
+
|
100
|
+
credentials
|
101
|
+
end
|
102
|
+
|
103
|
+
def parse(header)
|
104
|
+
parts = header.sub(/\AHawk\s+/, '').split(/,\s*/)
|
105
|
+
parts.inject(Hash.new) do |memo, part|
|
106
|
+
next memo unless part =~ %r{([a-z]+)=(['"])([^\2]+)\2}
|
107
|
+
key, val = $1, $3
|
108
|
+
memo[key.to_sym] = val
|
109
|
+
memo
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
end
|
data/lib/hawk/client.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
module Hawk
|
4
|
+
module Client
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def authenticate(authorization_header, options)
|
8
|
+
Hawk::AuthorizationHeader.authenticate(authorization_header, { :server_response => true }.merge(options))
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_authorization_header(options)
|
12
|
+
Hawk::AuthorizationHeader.build(options)
|
13
|
+
end
|
14
|
+
|
15
|
+
def calculate_time_offset(authorization_header, options)
|
16
|
+
parts = AuthorizationHeader.parse(authorization_header)
|
17
|
+
expected_mac = Crypto.ts_mac(:ts => parts[:ts], :credentials => options[:credentials])
|
18
|
+
return unless expected_mac == parts[:tsm]
|
19
|
+
parts[:ts].to_i - Time.now.to_i
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/hawk/crypto.rb
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'openssl'
|
3
|
+
|
4
|
+
##
|
5
|
+
# Ruby 1.8.7 compatibility
|
6
|
+
unless Base64.respond_to?(:strict_encode64)
|
7
|
+
Base64.class_eval do
|
8
|
+
def strict_encode64(bin)
|
9
|
+
[bin].pack("m0")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
unless Base64.respond_to?(:urlsafe_encode64)
|
14
|
+
Base64.class_eval do
|
15
|
+
def urlsafe_encode64(bin)
|
16
|
+
strict_encode64(bin).tr("+/", "-_").gsub("\n", '')
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module Hawk
|
22
|
+
module Crypto
|
23
|
+
extend self
|
24
|
+
|
25
|
+
def hash(options)
|
26
|
+
parts = []
|
27
|
+
|
28
|
+
parts << "hawk.1.payload"
|
29
|
+
parts << options[:content_type]
|
30
|
+
parts << options[:payload].to_s
|
31
|
+
parts << nil # trailing newline
|
32
|
+
|
33
|
+
Base64.encode64(OpenSSL::Digest.const_get(options[:credentials][:algorithm].upcase).digest(parts.join("\n"))).chomp
|
34
|
+
end
|
35
|
+
|
36
|
+
def normalized_string(options)
|
37
|
+
parts = []
|
38
|
+
|
39
|
+
parts << "hawk.1.#{options[:type] || 'header'}"
|
40
|
+
parts << options[:ts]
|
41
|
+
parts << options[:nonce]
|
42
|
+
parts << options[:method].to_s.upcase
|
43
|
+
parts << options[:path]
|
44
|
+
parts << options[:host]
|
45
|
+
parts << options[:port]
|
46
|
+
parts << options[:hash]
|
47
|
+
parts << options[:ext]
|
48
|
+
parts << nil # trailing newline
|
49
|
+
|
50
|
+
parts.join("\n")
|
51
|
+
end
|
52
|
+
|
53
|
+
def mac(options)
|
54
|
+
if !options[:hash] && options.has_key?(:payload)
|
55
|
+
options[:hash] = hash(options)
|
56
|
+
end
|
57
|
+
|
58
|
+
Base64.encode64(
|
59
|
+
OpenSSL::HMAC.digest(
|
60
|
+
openssl_digest(options[:credentials][:algorithm]).new,
|
61
|
+
options[:credentials][:key],
|
62
|
+
normalized_string(options)
|
63
|
+
)
|
64
|
+
).chomp
|
65
|
+
end
|
66
|
+
|
67
|
+
def ts_mac(options)
|
68
|
+
Base64.encode64(
|
69
|
+
OpenSSL::HMAC.digest(
|
70
|
+
openssl_digest(options[:credentials][:algorithm]).new,
|
71
|
+
options[:credentials][:key],
|
72
|
+
"hawk.1.ts\n#{options[:ts]}\n"
|
73
|
+
)
|
74
|
+
).chomp
|
75
|
+
end
|
76
|
+
|
77
|
+
def bewit(options)
|
78
|
+
options[:ts] ||= Time.now.to_i + options[:ttl].to_i
|
79
|
+
|
80
|
+
_mac = mac(options.merge(:type => 'bewit'))
|
81
|
+
|
82
|
+
parts = []
|
83
|
+
|
84
|
+
parts << options[:credentials][:id]
|
85
|
+
parts << options[:ts]
|
86
|
+
parts << _mac
|
87
|
+
parts << options[:ext]
|
88
|
+
|
89
|
+
Base64.urlsafe_encode64(parts.join("\\")).chomp.sub(/=+\Z/, '')
|
90
|
+
end
|
91
|
+
|
92
|
+
def openssl_digest(algorithm)
|
93
|
+
OpenSSL::Digest.const_get(algorithm.upcase)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|