oauthenticator 0.1.4 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,81 @@
1
+ module OAuthenticator
2
+ # OAuthenticator::Error represents some problem with authenticating. it has an #errors attribute with error
3
+ # messages in the form we use.
4
+ class Error < StandardError
5
+ # @param message [String]
6
+ # @param errors [Hash<String, Array<String>>]
7
+ def initialize(message, errors=nil)
8
+ super(message)
9
+ @errors = errors
10
+ end
11
+
12
+ # @return [Hash<String, Array<String>>]
13
+ def errors
14
+ @errors ||= Hash.new { |h,k| h[k] = [] }
15
+ end
16
+ end
17
+
18
+ # an error parsing an authorization header in .parse_authorization
19
+ class ParseError < Error; end
20
+
21
+ # an error indicating duplicated paramaters present in an authorization header, in violation of section 3.1
22
+ # ("Each parameter MUST NOT appear more than once per request.") and 3.2 ("The server SHOULD return a 400
23
+ # (Bad Request) status code when receiving a request with unsupported parameters, an unsupported signature
24
+ # method, missing parameters, or duplicated protocol parameters.")
25
+ class DuplicatedParameters < Error; end
26
+
27
+ class << self
28
+ # @param header [String] an Authorization header
29
+ # @return [Hash<String, String>] parsed authorization parameters
30
+ # @raise [OAuthenticator::ParseError] if the header is not well-formed and cannot be parsed
31
+ # @raise [OAuthenticator::DuplicatedParameters] if the header contains multiple instances of the same param
32
+ def parse_authorization(header)
33
+ header = header.to_s
34
+ scanner = StringScanner.new(header)
35
+ auth_parse_error = proc { |message| raise ParseError.new(message, {'Authorization' => [message]}) }
36
+ scanner.scan(/OAuth\s*/i) || auth_parse_error.call("Authorization scheme is not OAuth - recieved: #{header}")
37
+ attributes = Hash.new { |h,k| h[k] = [] }
38
+ while match = scanner.scan(/(\w+)="([^"]*)"\s*(,?)\s*/)
39
+ key = scanner[1]
40
+ value = scanner[2]
41
+ comma_follows = !scanner[3].empty?
42
+ if !comma_follows && !scanner.eos?
43
+ auth_parse_error.call("Could not parse Authorization header: #{header}\naround or after character #{scanner.pos}: #{scanner.rest}")
44
+ end
45
+ attributes[unescape(key)] << unescape(value)
46
+ end
47
+ unless scanner.eos?
48
+ auth_parse_error.call("Could not parse Authorization header: #{header}\naround or after character #{scanner.pos}: #{scanner.rest}")
49
+ end
50
+ duplicates = attributes.select { |k,v| v.size > 1 }
51
+ if duplicates.any?
52
+ errors = duplicates.map do |k,vs|
53
+ {k => "Received multiple instances of Authorization parameter #{k}. Received values were: #{vs.inspect}"}
54
+ end.inject({}, &:update)
55
+ raise DuplicatedParameters.new("Received duplicate parameters: #{duplicates.keys.inspect}", errors)
56
+ end
57
+ return attributes.map { |k,v| {k => v.first} }.inject({}, &:update)
58
+ end
59
+
60
+ # escape a value
61
+ # @param value [String] value
62
+ # @return [String] escaped value
63
+ def escape(value)
64
+ uri_parser.escape(value.to_s, /[^a-z0-9\-\.\_\~]/i)
65
+ end
66
+
67
+ # unescape a value
68
+ # @param value [String] escaped value
69
+ # @return [String] unescaped value
70
+ def unescape(value)
71
+ uri_parser.unescape(value.to_s)
72
+ end
73
+
74
+ private
75
+
76
+ # @return [Object] a parser that responds to #escape and #unescape
77
+ def uri_parser
78
+ @uri_parser ||= URI.const_defined?(:Parser) ? URI::Parser.new : URI
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,4 @@
1
1
  require 'rack'
2
- require 'simple_oauth'
3
2
  require 'json'
4
3
  require 'oauthenticator/signed_request'
5
4
 
@@ -11,16 +10,21 @@ module OAuthenticator
11
10
  # structured like rails / ActiveResource:
12
11
  #
13
12
  # {'errors': {'attribute1': ['messageA', 'messageB'], 'attribute2': ['messageC']}}
14
- class Middleware
13
+ class RackAuthenticator
15
14
  # options:
16
15
  #
17
16
  # - `:bypass` - a proc which will be called with a Rack::Request, which must have a boolean result.
18
- # if the result is true, authorization checking is bypassed. if false, the request is authenticated
17
+ # if the result is true, authentication checking is bypassed. if false, the request is authenticated
19
18
  # and responds 401 if not authenticated.
20
19
  #
21
20
  # - `:config_methods` - a Module which defines necessary methods for an {OAuthenticator::SignedRequest} to
22
21
  # determine if it is validly signed. See documentation for {OAuthenticator::ConfigMethods}
23
22
  # for details of what this module must implement.
23
+ #
24
+ # - `:logger` - a Logger instance to which OAuthenticator::RackAuthenticator will log informative messages
25
+ #
26
+ # - `:realm` - 401 responses include a `WWW-Authenticate` with the realm set to the given value. default
27
+ # is an empty string.
24
28
  def initialize(app, options={})
25
29
  @app=app
26
30
  @options = options
@@ -40,22 +44,46 @@ module OAuthenticator
40
44
  oauth_signed_request_class = OAuthenticator::SignedRequest.including_config(@options[:config_methods])
41
45
  oauth_request = oauth_signed_request_class.from_rack_request(request)
42
46
  if oauth_request.errors
43
- unauthorized_response({'errors' => oauth_request.errors})
47
+ log_unauthenticated(env, oauth_request)
48
+ unauthenticated_response({'errors' => oauth_request.errors})
44
49
  else
50
+ log_success(env, oauth_request)
45
51
  env["oauth.consumer_key"] = oauth_request.consumer_key
46
- env["oauth.access_token"] = oauth_request.token
52
+ env["oauth.token"] = oauth_request.token
47
53
  env["oauth.authenticated"] = true
48
54
  @app.call(env)
49
55
  end
50
56
  end
51
57
  end
52
58
 
53
- # the response for an unauthorized request. the argument will be a hash with the key 'errors', whose value
54
- # is a hash with string keys indicating attributes with errors, and values being arrays of strings
59
+ private
60
+
61
+ # the response for an unauthenticated request. the argument will be a hash with the key 'errors', whose
62
+ # value is a hash with string keys indicating attributes with errors, and values being arrays of strings
55
63
  # indicating error messages on the attribute key..
56
- def unauthorized_response(error_object)
57
- response_headers = {"WWW-Authenticate" => %q(OAuth realm="/"), 'Content-Type' => 'application/json'}
64
+ def unauthenticated_response(error_object)
65
+ # default to a blank realm, I suppose
66
+ realm = @options[:realm] || ''
67
+ response_headers = {"WWW-Authenticate" => %Q(OAuth realm="#{realm}"), 'Content-Type' => 'application/json'}
58
68
  [401, response_headers, [JSON.pretty_generate(error_object)]]
59
69
  end
70
+
71
+ # write a log entry regarding an unauthenticated request
72
+ def log_unauthenticated(env, oauth_request)
73
+ log :warn, "OAuthenticator rejected a request:\n" +
74
+ "\tAuthorization: #{env['HTTP_AUTHORIZATION']}\n" +
75
+ "\tErrors: #{JSON.generate(oauth_request.errors)}"
76
+ end
77
+
78
+ # write a log entry for a successfully authenticated request
79
+ def log_success(env, oauth_request)
80
+ log :info, "OAuthenticator authenticated an authentic request with Authorization: #{env['HTTP_AUTHORIZATION']}"
81
+ end
82
+
83
+ def log(level, message)
84
+ if @options[:logger]
85
+ @options[:logger].send(level, message)
86
+ end
87
+ end
60
88
  end
61
89
  end
@@ -0,0 +1,340 @@
1
+ require 'openssl'
2
+ require 'uri'
3
+ require 'base64'
4
+ require 'cgi'
5
+ require 'strscan'
6
+ require 'oauthenticator/parse_authorization'
7
+
8
+ module OAuthenticator
9
+ # a request which may be signed with OAuth, generally in order to apply the signature to an outgoing request
10
+ # in the Authorization header.
11
+ #
12
+ # primarily this is to be used like:
13
+ #
14
+ # oauthenticator_signable_request = OAuthenticator::SignableRequest.new(
15
+ # :request_method => my_request_method,
16
+ # :uri => my_request_uri,
17
+ # :media_type => my_request_media_type,
18
+ # :body => my_request_body,
19
+ # :signature_method => my_oauth_signature_method,
20
+ # :consumer_key => my_oauth_consumer_key,
21
+ # :consumer_secret => my_oauth_consumer_secret,
22
+ # :token => my_oauth_token,
23
+ # :token_secret => my_oauth_token_secret,
24
+ # :realm => my_authorization_realm
25
+ # )
26
+ # my_http_request.headers['Authorization'] = oauthenticator_signable_request.authorization
27
+ class SignableRequest
28
+ # keys of OAuth protocol parameters which form the Authorization header (with an oauth_ prefix).
29
+ # signature is considered separately.
30
+ PROTOCOL_PARAM_KEYS = %w(consumer_key token signature_method timestamp nonce version).map(&:freeze).freeze
31
+
32
+ # initialize a signable request with the following attributes (keys may be string or symbol):
33
+ #
34
+ # - request_method (required) - get, post, etc. may be string or symbol.
35
+ # - uri (required) - request URI. to_s is called so URI or Addressable::URI or whatever may be passed.
36
+ # - media_type (required) - the request media type (may be nil if there is no body). note that this may be
37
+ # different than the Content-Type header; other components of that such as encoding must not be included.
38
+ # - body (required) - the request body. may be a String or an IO, or nil if no body is present.
39
+ # - hash_body? - whether to add the oauth_body_hash parameter, per the OAuth Request Body Hash
40
+ # specification. defaults to true. not used if the 'authorization' parameter is used.
41
+ # - signature_method (required*) - oauth signature method (String)
42
+ # - consumer_key (required*) - oauth consumer key (String)
43
+ # - consumer_secret (required*) - oauth consumer secret (String)
44
+ # - token (optional*) - oauth token; may be omitted if only using a consumer key (two-legged)
45
+ # - token_secret (optional) - must be present if token is present. must be omitted if token is omitted.
46
+ # - timestamp (optional*) - if omitted, defaults to the current time.
47
+ # if nil is passed, no oauth_timestamp will be present in the generated authorization.
48
+ # - nonce (optional*) - if omitted, defaults to a random string.
49
+ # if nil is passed, no oauth_nonce will be present in the generated authorization.
50
+ # - version (optional*) - must be nil or '1.0'. defaults to '1.0' if omitted.
51
+ # if nil is passed, no oauth_version will be present in the generated authorization.
52
+ # - realm (optional) - authorization realm.
53
+ # if nil is passed, no realm will be present in the generated authorization.
54
+ # - authorization - a hash of a received Authorization header, the result of a call to
55
+ # OAuthenticator.parse_authorization. it is useful for calculating the signature of a received request,
56
+ # but for fully authenticating a received request it is generally preferable to use
57
+ # OAuthenticator::SignedRequest. specifying this precludes the requirement to specify any of
58
+ # PROTOCOL_PARAM_KEYS.
59
+ #
60
+ # (*) attributes which are in PROTOCOL_PARAM_KEYS are unused (and not required) when the
61
+ # 'authorization' attribute is given for signature verification. normally, though, they are used and
62
+ # are required or optional as noted.
63
+ def initialize(attributes)
64
+ raise TypeError, "attributes must be a hash" unless attributes.is_a?(Hash)
65
+ # stringify symbol keys
66
+ @attributes = attributes.map { |k,v| {k.is_a?(Symbol) ? k.to_s : k => v} }.inject({}, &:update)
67
+
68
+ # validation - presence
69
+ required = %w(request_method uri media_type body)
70
+ required += %w(signature_method consumer_key) unless @attributes['authorization']
71
+ missing = required - @attributes.keys
72
+ raise ArgumentError, "missing: #{missing.inspect}" if missing.any?
73
+ other_recognized = PROTOCOL_PARAM_KEYS + %w(authorization consumer_secret token_secret realm hash_body?)
74
+ extra = @attributes.keys - (required + other_recognized)
75
+ raise ArgumentError, "received unrecognized @attributes: #{extra.inspect}" if extra.any?
76
+
77
+ if @attributes['authorization']
78
+ # this means we are signing an existing request to validate the received signature. don't use defaults.
79
+ unless @attributes['authorization'].is_a?(Hash)
80
+ raise TypeError, "authorization must be a Hash"
81
+ end
82
+
83
+ # if authorization is specified, protocol params should not be specified in the regular attributes
84
+ given_protocol_params = @attributes.reject { |k,v| !(PROTOCOL_PARAM_KEYS.include?(k) && v) }
85
+ if given_protocol_params.any?
86
+ raise ArgumentError, "an existing authorization was given, but protocol parameters were also " +
87
+ "given. protocol parameters should not be specified when verifying an existing authorization. " +
88
+ "given protocol parameters were: #{given_protocol_params.inspect}"
89
+ end
90
+ else
91
+ # defaults
92
+ defaults = {
93
+ 'version' => '1.0',
94
+ }
95
+ if @attributes['signature_method'] != 'PLAINTEXT'
96
+ defaults.update({
97
+ 'nonce' => OpenSSL::Random.random_bytes(16).unpack('H*')[0],
98
+ 'timestamp' => Time.now.to_i.to_s,
99
+ })
100
+ end
101
+ @attributes['authorization'] = PROTOCOL_PARAM_KEYS.map do |key|
102
+ {"oauth_#{key}" => @attributes.key?(key) ? @attributes[key] : defaults[key]}
103
+ end.inject({}, &:update).reject {|k,v| v.nil? }
104
+ @attributes['authorization']['realm'] = @attributes['realm'] unless @attributes['realm'].nil?
105
+
106
+ hash_body
107
+ end
108
+ end
109
+
110
+ # returns the Authorization header generated for this request.
111
+ #
112
+ # @return [String] Authorization header
113
+ def authorization
114
+ "OAuth #{normalized_protocol_params_string}"
115
+ end
116
+
117
+ # the oauth_signature calculated for this request.
118
+ #
119
+ # @return [String] oauth signature
120
+ def signature
121
+ rbmethod = SIGNATURE_METHODS[signature_method] ||
122
+ raise(ArgumentError, "invalid signature method: #{signature_method}")
123
+ rbmethod.bind(self).call
124
+ end
125
+
126
+ # the oauth_body_hash calculated for this request, if applicable, per the OAuth Request Body Hash
127
+ # specification.
128
+ #
129
+ # @return [String, nil] oauth body hash
130
+ def body_hash
131
+ BODY_HASH_METHODS[signature_method] ? BODY_HASH_METHODS[signature_method].bind(self).call : nil
132
+ end
133
+
134
+ # protocol params for this request as described in section 3.4.1.3
135
+ #
136
+ # signature is not calculated for this - use #signed_protocol_params to get protocol params including a
137
+ # signature.
138
+ #
139
+ # note that if this is a previously-signed request, the oauth_signature attribute returned is the
140
+ # received value, NOT the value calculated by us.
141
+ #
142
+ # @return [Hash<String, String>] protocol params
143
+ def protocol_params
144
+ @attributes['authorization'].dup
145
+ end
146
+
147
+ # protocol params for this request as described in section 3.4.1.3, including our calculated
148
+ # oauth_signature.
149
+ #
150
+ # @return [Hash<String, String>] signed protocol params
151
+ def signed_protocol_params
152
+ protocol_params.merge('oauth_signature' => signature)
153
+ end
154
+
155
+ # is the media type application/x-www-form-urlencoded
156
+ #
157
+ # @return [Boolean]
158
+ def form_encoded?
159
+ media_type = @attributes['media_type']
160
+ # media tye is case insensitive per http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
161
+ media_type = media_type.downcase if media_type.is_a?(String)
162
+ media_type == "application/x-www-form-urlencoded"
163
+ end
164
+
165
+ private
166
+
167
+ # signature base string for signing. section 3.4.1
168
+ #
169
+ # @return [String]
170
+ def signature_base
171
+ parts = [normalized_request_method, base_string_uri, normalized_request_params_string]
172
+ parts.map { |v| OAuthenticator.escape(v) }.join('&')
173
+ end
174
+
175
+ # section 3.4.1.2
176
+ #
177
+ # @return [String]
178
+ def base_string_uri
179
+ URI.parse(@attributes['uri'].to_s).tap do |uri|
180
+ uri.scheme = uri.scheme.downcase
181
+ uri.host = uri.host.downcase
182
+ uri.normalize!
183
+ uri.fragment = nil
184
+ uri.query = nil
185
+ end.to_s
186
+ end
187
+
188
+ # section 3.4.1.1
189
+ #
190
+ # @return [String]
191
+ def normalized_request_method
192
+ @attributes['request_method'].to_s.upcase
193
+ end
194
+
195
+ # section 3.4.1.3.2
196
+ #
197
+ # @return [String]
198
+ def normalized_request_params_string
199
+ normalized_request_params.map { |kv| kv.map { |v| OAuthenticator.escape(v) } }.sort.map { |p| p.join('=') }.join('&')
200
+ end
201
+
202
+ # section 3.4.1.3
203
+ #
204
+ # @return [Array<Array<String> (size 2)>]
205
+ def normalized_request_params
206
+ query_params + protocol_params.reject { |k,v| %w(realm oauth_signature).include?(k) }.to_a + entity_params
207
+ end
208
+
209
+ # section 3.4.1.3.1
210
+ #
211
+ # parsed query params, extracted from the request URI. since keys may appear multiple times, represented
212
+ # as an array of two-element arrays and not a hash
213
+ #
214
+ # @return [Array<Array<String, nil> (size 2)>]
215
+ def query_params
216
+ parse_form_encoded(URI.parse(@attributes['uri'].to_s).query || '')
217
+ end
218
+
219
+ # section 3.4.1.3.1
220
+ #
221
+ # parsed entity params from the body, when the request is form encoded. since keys may appear multiple
222
+ # times, represented as an array of two-element arrays and not a hash
223
+ #
224
+ # @return [Array<Array<String, nil> (size 2)>]
225
+ def entity_params
226
+ if form_encoded?
227
+ parse_form_encoded(read_body)
228
+ else
229
+ []
230
+ end
231
+ end
232
+
233
+ # like CGI.parse but it keeps keys without any value. doesn't keep blank keys though.
234
+ #
235
+ # @return [Array<Array<String, nil> (size 2)>]
236
+ def parse_form_encoded(data)
237
+ data.split(/[&;]/).map do |pair|
238
+ key, value = pair.split('=', 2).map { |v| CGI::unescape(v) }
239
+ [key, value] unless [nil, ''].include?(key)
240
+ end.compact
241
+ end
242
+
243
+ # string of protocol params including signature, sorted
244
+ #
245
+ # @return [String]
246
+ def normalized_protocol_params_string
247
+ signed_protocol_params.sort.map { |(k,v)| %Q(#{OAuthenticator.escape(k)}="#{OAuthenticator.escape(v)}") }.join(', ')
248
+ end
249
+
250
+ # reads the request body, be it String or IO
251
+ #
252
+ # @return [String] request body
253
+ def read_body
254
+ body = @attributes['body']
255
+ if body.nil?
256
+ ''
257
+ elsif body.is_a?(String)
258
+ body
259
+ elsif body.respond_to?(:read) && body.respond_to?(:rewind)
260
+ body.rewind
261
+ body.read.tap do
262
+ body.rewind
263
+ end
264
+ else
265
+ raise TypeError, "Body must be a String or something IO-like (responding to #read and #rewind). " +
266
+ "got body = #{body.inspect}"
267
+ end
268
+ end
269
+
270
+ # set the oauth_body_hash to the hash of the request body
271
+ #
272
+ # @return [Void]
273
+ def hash_body
274
+ if hash_body?
275
+ @attributes['authorization']['oauth_body_hash'] = body_hash
276
+ end
277
+ end
278
+
279
+ # whether we will hash the body, per oauth request body hash section 4.1, as well as whether the caller
280
+ # said to
281
+ #
282
+ # @return [Boolean]
283
+ def hash_body?
284
+ BODY_HASH_METHODS[signature_method] && !form_encoded? &&
285
+ (@attributes.key?('hash_body?') ? @attributes['hash_body?'] : true)
286
+ end
287
+
288
+ # signature method
289
+ #
290
+ # @return [String]
291
+ def signature_method
292
+ @attributes['authorization']['oauth_signature_method']
293
+ end
294
+
295
+ # signature, with method RSA-SHA1. section 3.4.3
296
+ #
297
+ # @return [String]
298
+ def rsa_sha1_signature
299
+ private_key = OpenSSL::PKey::RSA.new(@attributes['consumer_secret'])
300
+ Base64.encode64(private_key.sign(OpenSSL::Digest::SHA1.new, signature_base)).chomp.gsub(/\n/, '')
301
+ end
302
+
303
+ # signature, with method HMAC-SHA1. section 3.4.2
304
+ #
305
+ # @return [String]
306
+ def hmac_sha1_signature
307
+ # hmac secret is same as plaintext signature
308
+ secret = plaintext_signature
309
+ Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, secret, signature_base)).chomp.gsub(/\n/, '')
310
+ end
311
+
312
+ # signature, with method plaintext. section 3.4.4
313
+ #
314
+ # @return [String]
315
+ def plaintext_signature
316
+ @attributes.values_at('consumer_secret', 'token_secret').map { |v| OAuthenticator.escape(v) }.join('&')
317
+ end
318
+
319
+ # body hash, with a signature method which uses SHA1. oauth request body hash section 3.2
320
+ #
321
+ # @return [String]
322
+ def sha1_body_hash
323
+ Base64.encode64(OpenSSL::Digest::SHA1.digest(read_body)).chomp.gsub(/\n/, '')
324
+ end
325
+
326
+ # map of oauth signature methods to their signature instance methods on this class
327
+ SIGNATURE_METHODS = {
328
+ 'RSA-SHA1'.freeze => instance_method(:rsa_sha1_signature),
329
+ 'HMAC-SHA1'.freeze => instance_method(:hmac_sha1_signature),
330
+ 'PLAINTEXT'.freeze => instance_method(:plaintext_signature),
331
+ }.freeze
332
+
333
+ # map of oauth signature methods to their body hash instance methods on this class. oauth request body
334
+ # hash section 3.1
335
+ BODY_HASH_METHODS = {
336
+ 'RSA-SHA1'.freeze => instance_method(:sha1_body_hash),
337
+ 'HMAC-SHA1'.freeze => instance_method(:sha1_body_hash),
338
+ }.freeze
339
+ end
340
+ end