hawk-auth 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 [](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
|