mauth-client 4.0.1
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 +8 -0
- data/.travis.yml +13 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +119 -0
- data/CONTRIBUTING.md +20 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +19 -0
- data/README.md +235 -0
- data/Rakefile +7 -0
- data/doc/implementations.md +6 -0
- data/doc/mauth-client_CLI.md +59 -0
- data/doc/mauth-proxy.md +71 -0
- data/doc/mauth.yml.md +73 -0
- data/examples/Gemfile +4 -0
- data/examples/Gemfile.lock +41 -0
- data/examples/README.md +33 -0
- data/examples/config.yml +8 -0
- data/examples/get_user_info.rb +58 -0
- data/examples/mauth_key +0 -0
- data/exe/mauth-client +264 -0
- data/exe/mauth-proxy +40 -0
- data/lib/mauth/autoload.rb +8 -0
- data/lib/mauth/client.rb +487 -0
- data/lib/mauth/core_ext.rb +7 -0
- data/lib/mauth/dice_bag/mauth.rb.dice +12 -0
- data/lib/mauth/dice_bag/mauth.yml.dice +14 -0
- data/lib/mauth/dice_bag/mauth_key.dice +1 -0
- data/lib/mauth/dice_bag/mauth_templates.rb +19 -0
- data/lib/mauth/fake/rack.rb +45 -0
- data/lib/mauth/faraday.rb +87 -0
- data/lib/mauth/middleware.rb +23 -0
- data/lib/mauth/proxy.rb +77 -0
- data/lib/mauth/rack.rb +137 -0
- data/lib/mauth/request_and_response.rb +73 -0
- data/lib/mauth/version.rb +3 -0
- data/lib/mauth-client.rb +1 -0
- data/lib/rack/mauth.rb +1 -0
- data/mauth-client.gemspec +36 -0
- metadata +292 -0
data/lib/mauth/client.rb
ADDED
@@ -0,0 +1,487 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'openssl'
|
3
|
+
require 'base64'
|
4
|
+
require 'json'
|
5
|
+
require 'yaml'
|
6
|
+
require 'mauth/core_ext'
|
7
|
+
require 'mauth/autoload'
|
8
|
+
require 'mauth/dice_bag/mauth_templates'
|
9
|
+
require 'mauth/version'
|
10
|
+
|
11
|
+
module MAuth
|
12
|
+
class Client
|
13
|
+
class << self
|
14
|
+
# returns a configuration (to be passed to MAuth::Client.new) which is configured from information stored in
|
15
|
+
# standard places. all of which is overridable by options in case some defaults do not apply.
|
16
|
+
#
|
17
|
+
# options (may be symbols or strings) - any or all may be omitted where your usage conforms to the defaults.
|
18
|
+
# - root: the path relative to which this method looks for configuration yaml files. defaults to Rails.root
|
19
|
+
# if ::Rails is defined, otherwise ENV['RAILS_ROOT'], ENV['RACK_ROOT'], ENV['APP_ROOT'], or '.'
|
20
|
+
# - environment: the environment, pertaining to top-level keys of the configuration yaml files. by default,
|
21
|
+
# tries Rails.environment, ENV['RAILS_ENV'], and ENV['RACK_ENV'], and falls back to 'development' if none
|
22
|
+
# of these are set.
|
23
|
+
# - mauth_config - MAuth configuration. defaults to load this from a yaml file (see mauth_config_yml option)
|
24
|
+
# which is assumed to be keyed with the environment at the root. if this is specified, no yaml file is
|
25
|
+
# loaded, and the given config is passed through with any other defaults applied. at the moment, the only
|
26
|
+
# other default is to set the logger.
|
27
|
+
# - mauth_config_yml - specifies where a mauth configuration yaml file can be found. by default checks
|
28
|
+
# ENV['MAUTH_CONFIG_YML'] or a file 'config/mauth.yml' relative to the root.
|
29
|
+
# - logger - by default checks ::Rails.logger
|
30
|
+
def default_config(options = {})
|
31
|
+
options = options.stringify_symbol_keys
|
32
|
+
|
33
|
+
# find the app_root (relative to which we look for yaml files). note that this
|
34
|
+
# is different than MAuth::Client.root, the root of the mauth-client library.
|
35
|
+
app_root = options['root'] || begin
|
36
|
+
if Object.const_defined?('Rails') && ::Rails.respond_to?(:root) && ::Rails.root
|
37
|
+
Rails.root
|
38
|
+
else
|
39
|
+
ENV['RAILS_ROOT'] || ENV['RACK_ROOT'] || ENV['APP_ROOT'] || '.'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# find the environment (with which yaml files are keyed)
|
44
|
+
env = options['environment'] || begin
|
45
|
+
if Object.const_defined?('Rails') && ::Rails.respond_to?(:environment)
|
46
|
+
Rails.environment
|
47
|
+
else
|
48
|
+
ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# find mauth config, given on options, or in a file at
|
53
|
+
# ENV['MAUTH_CONFIG_YML'] or config/mauth.yml in the app_root
|
54
|
+
mauth_config = options['mauth_config'] || begin
|
55
|
+
mauth_config_yml = options['mauth_config_yml']
|
56
|
+
mauth_config_yml ||= ENV['MAUTH_CONFIG_YML']
|
57
|
+
default_loc = 'config/mauth.yml'
|
58
|
+
default_yml = File.join(app_root, default_loc)
|
59
|
+
mauth_config_yml ||= default_yml if File.exist?(default_yml)
|
60
|
+
if mauth_config_yml && File.exist?(mauth_config_yml)
|
61
|
+
whole_config = YAML.load_file(mauth_config_yml)
|
62
|
+
errmessage = "#{mauth_config_yml} config has no key #{env} - it has keys #{whole_config.keys.inspect}"
|
63
|
+
whole_config[env] || raise(MAuth::Client::ConfigurationError, errmessage)
|
64
|
+
else
|
65
|
+
raise MAuth::Client::ConfigurationError, "could not find mauth config yaml file. this file may be " \
|
66
|
+
"placed in #{default_loc}, specified with the mauth_config_yml option, or specified with the " \
|
67
|
+
"MAUTH_CONFIG_YML environment variable."
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
unless mauth_config.key?('logger')
|
72
|
+
# the logger. Rails.logger if it exists, otherwise, no logger
|
73
|
+
mauth_config['logger'] = options['logger'] || begin
|
74
|
+
if Object.const_defined?('Rails') && ::Rails.respond_to?(:logger)
|
75
|
+
Rails.logger
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
mauth_config
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
module MAuth
|
87
|
+
# mAuth client was unable to verify the authenticity of a signed object (this does NOT mean the
|
88
|
+
# object is inauthentic). typically due to a failure communicating with the mAuth service, in
|
89
|
+
# which case the error may include the attribute mauth_service_response - a response from
|
90
|
+
# the mauth service (if it was contactable at all), which may contain more information about
|
91
|
+
# the error.
|
92
|
+
class UnableToAuthenticateError < StandardError
|
93
|
+
# the response from the MAuth service encountered when attempting to retrieve authentication
|
94
|
+
attr_accessor :mauth_service_response
|
95
|
+
end
|
96
|
+
|
97
|
+
# used to indicate that an object was expected to be validly signed but its signature does not
|
98
|
+
# match its contents, and so is inauthentic.
|
99
|
+
class InauthenticError < StandardError
|
100
|
+
end
|
101
|
+
|
102
|
+
# required information for signing was missing
|
103
|
+
class UnableToSignError < StandardError
|
104
|
+
end
|
105
|
+
|
106
|
+
# does operations which require a private key and corresponding app uuid. this is primarily:
|
107
|
+
# - signing outgoing requests and responses
|
108
|
+
# - authenticating incoming requests and responses, which may require retrieving the appropriate
|
109
|
+
# public key from mAuth (which requires a request to mAuth which is signed using the private
|
110
|
+
# key)
|
111
|
+
#
|
112
|
+
# this nominally operates on request and response objects, but really the only requirements are
|
113
|
+
# that the object responds to the methods of MAuth::Signable and/or MAuth::Signed (as
|
114
|
+
# appropriate)
|
115
|
+
class Client
|
116
|
+
class ConfigurationError < StandardError; end
|
117
|
+
|
118
|
+
MWS_TOKEN = 'MWS'.freeze
|
119
|
+
|
120
|
+
# new client with the given App UUID and public key. config may include the following (all
|
121
|
+
# config keys may be strings or symbols):
|
122
|
+
# - private_key - required for signing and for authenticating responses. may be omitted if
|
123
|
+
# only remote authentication of requests is being performed (with
|
124
|
+
# MAuth::Rack::RequestAuthenticator). may be given as a string or a OpenSSL::PKey::RSA
|
125
|
+
# instance.
|
126
|
+
# - app_uuid - required in the same circumstances where a private_key is required
|
127
|
+
# - mauth_baseurl - required. needed for local authentication to retrieve public keys; needed
|
128
|
+
# for remote authentication for hopefully obvious reasons.
|
129
|
+
# - mauth_api_version - required. only 'v1' exists / is supported as of this writing.
|
130
|
+
# - logger - a Logger to which any useful information will be written. if this is omitted and
|
131
|
+
# Rails.logger exists, that will be used.
|
132
|
+
# - authenticator - this pretty much never needs to be specified. LocalAuthenticator or
|
133
|
+
# RemoteRequestAuthenticator will be used as appropriate.
|
134
|
+
def initialize(config = {})
|
135
|
+
# stringify symbol keys
|
136
|
+
given_config = config.stringify_symbol_keys
|
137
|
+
# build a configuration which discards any irrelevant parts of the given config (small memory usage matters here)
|
138
|
+
@config = {}
|
139
|
+
if given_config['private_key_file'] && !given_config['private_key']
|
140
|
+
given_config['private_key'] = File.read(given_config['private_key_file'])
|
141
|
+
end
|
142
|
+
@config['private_key'] = case given_config['private_key']
|
143
|
+
when nil
|
144
|
+
nil
|
145
|
+
when String
|
146
|
+
OpenSSL::PKey::RSA.new(given_config['private_key'])
|
147
|
+
when OpenSSL::PKey::RSA
|
148
|
+
given_config['private_key']
|
149
|
+
else
|
150
|
+
raise MAuth::Client::ConfigurationError, "unrecognized value given for 'private_key' - this may be a " \
|
151
|
+
"String, a OpenSSL::PKey::RSA, or omitted; instead got: #{given_config['private_key'].inspect}"
|
152
|
+
end
|
153
|
+
@config['app_uuid'] = given_config['app_uuid']
|
154
|
+
@config['mauth_baseurl'] = given_config['mauth_baseurl']
|
155
|
+
@config['mauth_api_version'] = given_config['mauth_api_version']
|
156
|
+
@config['logger'] = given_config['logger'] || begin
|
157
|
+
if Object.const_defined?('Rails') && Rails.logger
|
158
|
+
Rails.logger
|
159
|
+
else
|
160
|
+
require 'logger'
|
161
|
+
is_win = RUBY_PLATFORM =~ /mswin|windows|mingw32|cygwin/i
|
162
|
+
null_device = is_win ? 'NUL' : '/dev/null'
|
163
|
+
::Logger.new(File.open(null_device, File::WRONLY))
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
request_config = { timeout: 10, open_timeout: 10 }
|
168
|
+
request_config.merge!(symbolize_keys(given_config['faraday_options'])) if given_config['faraday_options']
|
169
|
+
@config['faraday_options'] = { request: request_config } || {}
|
170
|
+
@config['ssl_certs_path'] = given_config['ssl_certs_path'] if given_config['ssl_certs_path']
|
171
|
+
|
172
|
+
# if 'authenticator' was given, don't override that - including if it was given as nil / false
|
173
|
+
if given_config.key?('authenticator')
|
174
|
+
@config['authenticator'] = given_config['authenticator']
|
175
|
+
else
|
176
|
+
if client_app_uuid && private_key
|
177
|
+
# MAuth::Client can authenticate locally if it's provided a client_app_uuid and private_key
|
178
|
+
@config['authenticator'] = LocalAuthenticator
|
179
|
+
else
|
180
|
+
# otherwise, it will authenticate remotely (requests only)
|
181
|
+
@config['authenticator'] = RemoteRequestAuthenticator
|
182
|
+
end
|
183
|
+
end
|
184
|
+
extend @config['authenticator'] if @config['authenticator']
|
185
|
+
end
|
186
|
+
|
187
|
+
def logger
|
188
|
+
@config['logger']
|
189
|
+
end
|
190
|
+
|
191
|
+
def client_app_uuid
|
192
|
+
@config['app_uuid']
|
193
|
+
end
|
194
|
+
|
195
|
+
def mauth_baseurl
|
196
|
+
@config['mauth_baseurl'] || raise(MAuth::Client::ConfigurationError, "no configured mauth_baseurl!")
|
197
|
+
end
|
198
|
+
|
199
|
+
def mauth_api_version
|
200
|
+
@config['mauth_api_version'] || raise(MAuth::Client::ConfigurationError, "no configured mauth_api_version!")
|
201
|
+
end
|
202
|
+
|
203
|
+
def private_key
|
204
|
+
@config['private_key']
|
205
|
+
end
|
206
|
+
|
207
|
+
def faraday_options
|
208
|
+
@config['faraday_options']
|
209
|
+
end
|
210
|
+
|
211
|
+
def ssl_certs_path
|
212
|
+
@config['ssl_certs_path']
|
213
|
+
end
|
214
|
+
|
215
|
+
def assert_private_key(err)
|
216
|
+
raise err unless private_key
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
def mauth_service_response_error(response)
|
222
|
+
message = "mAuth service responded with #{response.status}: #{response.body}"
|
223
|
+
logger.error(message)
|
224
|
+
error = UnableToAuthenticateError.new(message)
|
225
|
+
error.mauth_service_response = response
|
226
|
+
raise error
|
227
|
+
end
|
228
|
+
|
229
|
+
# Changes all keys in the top level of the hash to symbols. Does not affect nested hashes inside this one.
|
230
|
+
def symbolize_keys(hash)
|
231
|
+
hash.keys.each do |key|
|
232
|
+
hash[(key.to_sym rescue key) || key] = hash.delete(key)
|
233
|
+
end
|
234
|
+
hash
|
235
|
+
end
|
236
|
+
|
237
|
+
# methods to sign requests and responses. part of MAuth::Client
|
238
|
+
module Signer
|
239
|
+
# takes an outgoing request or response object, and returns an object of the same class
|
240
|
+
# whose headers are updated to include mauth's signature headers
|
241
|
+
def signed(object, attributes = {})
|
242
|
+
object.merge_headers(signed_headers(object, attributes))
|
243
|
+
end
|
244
|
+
|
245
|
+
# takes a signable object (outgoing request or response). returns a hash of headers to be
|
246
|
+
# applied tothe object which comprise its signature.
|
247
|
+
def signed_headers(object, attributes = {})
|
248
|
+
attributes = { time: Time.now.to_i.to_s, app_uuid: client_app_uuid }.merge(attributes)
|
249
|
+
signature = self.signature(object, attributes)
|
250
|
+
{ 'X-MWS-Authentication' => "#{MWS_TOKEN} #{client_app_uuid}:#{signature}", 'X-MWS-Time' => attributes[:time] }
|
251
|
+
end
|
252
|
+
|
253
|
+
# takes a signable object (outgoing request or response). returns a mauth signature string
|
254
|
+
# for that object.
|
255
|
+
def signature(object, attributes = {})
|
256
|
+
assert_private_key(UnableToSignError.new("mAuth client cannot sign without a private key!"))
|
257
|
+
attributes = { time: Time.now.to_i.to_s, app_uuid: client_app_uuid }.merge(attributes)
|
258
|
+
signature = Base64.encode64(private_key.private_encrypt(object.string_to_sign(attributes))).delete("\n")
|
259
|
+
end
|
260
|
+
end
|
261
|
+
include Signer
|
262
|
+
|
263
|
+
# methods common to RemoteRequestAuthenticator and LocalAuthenticator
|
264
|
+
module Authenticator
|
265
|
+
ALLOWED_DRIFT_SECONDS = 300
|
266
|
+
|
267
|
+
# takes an incoming request or response object, and returns whether
|
268
|
+
# the object is authentic according to its signature.
|
269
|
+
def authentic?(object)
|
270
|
+
log_authentication_request(object)
|
271
|
+
begin
|
272
|
+
authenticate!(object)
|
273
|
+
true
|
274
|
+
rescue InauthenticError
|
275
|
+
false
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# raises InauthenticError unless the given object is authentic
|
280
|
+
def authenticate!(object)
|
281
|
+
authentication_present!(object)
|
282
|
+
time_valid!(object)
|
283
|
+
token_valid!(object)
|
284
|
+
signature_valid!(object)
|
285
|
+
rescue InauthenticError
|
286
|
+
logger.error "mAuth signature authentication failed for #{object.class}. encountered error:"
|
287
|
+
$!.message.split("\n").each { |l| logger.error "\t#{l}" }
|
288
|
+
raise
|
289
|
+
rescue UnableToAuthenticateError
|
290
|
+
logger.error "Unable to authenticate with MAuth. encountered error:"
|
291
|
+
$!.message.split("\n").each { |l| logger.error "\t#{l}" }
|
292
|
+
raise
|
293
|
+
end
|
294
|
+
|
295
|
+
private
|
296
|
+
|
297
|
+
# Note: This log is likely consumed downstream and the contents SHOULD NOT be changed without a thorough review of downstream consumers.
|
298
|
+
def log_authentication_request(object)
|
299
|
+
object_app_uuid = object.signature_app_uuid || '[none provided]'
|
300
|
+
logger.info "Mauth-client attempting to authenticate request from app with mauth app uuid #{object_app_uuid} to app with mauth app uuid #{client_app_uuid}."
|
301
|
+
rescue # don't let a failed attempt to log disrupt the rest of the action
|
302
|
+
logger.error "Mauth-client failed to log information about its attempts to authenticate the current request because #{$!}"
|
303
|
+
end
|
304
|
+
|
305
|
+
def authentication_present!(object)
|
306
|
+
if object.x_mws_authentication.nil? || object.x_mws_authentication !~ /\S/
|
307
|
+
raise InauthenticError, "Authentication Failed. No mAuth signature present; X-MWS-Authentication header is blank."
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
def time_valid!(object, now = Time.now)
|
312
|
+
if object.x_mws_time.nil?
|
313
|
+
raise InauthenticError, "Time verification failed for #{object.class}. No x-mws-time present."
|
314
|
+
elsif !(-ALLOWED_DRIFT_SECONDS..ALLOWED_DRIFT_SECONDS).cover?(now.to_i - object.x_mws_time.to_i)
|
315
|
+
raise InauthenticError, "Time verification failed for #{object.class}. #{object.x_mws_time} not within #{ALLOWED_DRIFT_SECONDS} of #{now}"
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def token_valid!(object)
|
320
|
+
unless object.signature_token == MWS_TOKEN
|
321
|
+
raise InauthenticError, "Token verification failed for #{object.class}. Expected #{MWS_TOKEN.inspect}; token was #{object.signature_token}"
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
include Authenticator
|
326
|
+
|
327
|
+
# methods to verify the authenticity of signed requests and responses locally, retrieving
|
328
|
+
# public keys from the mAuth service as needed
|
329
|
+
module LocalAuthenticator
|
330
|
+
private
|
331
|
+
|
332
|
+
def signature_valid!(object)
|
333
|
+
# We are in an unfortunate situation in which Euresource is percent-encoding parts of paths, but not
|
334
|
+
# all of them. In particular, Euresource is percent-encoding all special characters save for '/'.
|
335
|
+
# Also, unfortunately, Nginx unencodes URIs before sending them off to served applications, though
|
336
|
+
# other web servers (particularly those we typically use for local testing) do not. The various forms
|
337
|
+
# of the expected string to sign are meant to cover the main cases.
|
338
|
+
# TODO: Revisit and simplify this unfortunate situation.
|
339
|
+
|
340
|
+
original_request_uri = object.attributes_for_signing[:request_url]
|
341
|
+
|
342
|
+
# craft an expected string-to-sign without doing any percent-encoding
|
343
|
+
expected_no_reencoding = object.string_to_sign(time: object.x_mws_time, app_uuid: object.signature_app_uuid)
|
344
|
+
|
345
|
+
# do a simple percent reencoding variant of the path
|
346
|
+
object.attributes_for_signing[:request_url] = CGI.escape(original_request_uri.to_s)
|
347
|
+
expected_for_percent_reencoding = object.string_to_sign(time: object.x_mws_time, app_uuid: object.signature_app_uuid)
|
348
|
+
|
349
|
+
# do a moderately complex Euresource-style reencoding of the path
|
350
|
+
object.attributes_for_signing[:request_url] = CGI.escape(original_request_uri.to_s)
|
351
|
+
object.attributes_for_signing[:request_url].gsub!('%2F', '/') # ...and then 'simply' decode the %2F's back into /'s, just like Euresource kind of does!
|
352
|
+
expected_euresource_style_reencoding = object.string_to_sign(time: object.x_mws_time, app_uuid: object.signature_app_uuid)
|
353
|
+
|
354
|
+
# reset the object original request_uri, just in case we need it again
|
355
|
+
object.attributes_for_signing[:request_url] = original_request_uri
|
356
|
+
|
357
|
+
pubkey = OpenSSL::PKey::RSA.new(retrieve_public_key(object.signature_app_uuid))
|
358
|
+
begin
|
359
|
+
actual = pubkey.public_decrypt(Base64.decode64(object.signature))
|
360
|
+
rescue OpenSSL::PKey::PKeyError
|
361
|
+
raise InauthenticError, "Public key decryption of signature failed!\n#{$!.class}: #{$!.message}"
|
362
|
+
end
|
363
|
+
# TODO: time-invariant comparison instead of #== ?
|
364
|
+
unless expected_no_reencoding == actual || expected_euresource_style_reencoding == actual || expected_for_percent_reencoding == actual
|
365
|
+
raise InauthenticError, "Signature verification failed for #{object.class}"
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def retrieve_public_key(app_uuid)
|
370
|
+
retrieve_security_token(app_uuid)['security_token']['public_key_str']
|
371
|
+
end
|
372
|
+
|
373
|
+
def retrieve_security_token(app_uuid)
|
374
|
+
security_token_cacher.get(app_uuid)
|
375
|
+
end
|
376
|
+
|
377
|
+
def security_token_cacher
|
378
|
+
@security_token_cacher ||= SecurityTokenCacher.new(self)
|
379
|
+
end
|
380
|
+
class SecurityTokenCacher
|
381
|
+
class ExpirableSecurityToken < Struct.new(:security_token, :create_time)
|
382
|
+
CACHE_LIFE = 60
|
383
|
+
def expired?
|
384
|
+
create_time + CACHE_LIFE < Time.now
|
385
|
+
end
|
386
|
+
end
|
387
|
+
def initialize(mauth_client)
|
388
|
+
@mauth_client = mauth_client
|
389
|
+
# TODO: should this be UnableToSignError?
|
390
|
+
@mauth_client.assert_private_key(UnableToAuthenticateError.new("Cannot fetch public keys from mAuth service without a private key!"))
|
391
|
+
@cache = {}
|
392
|
+
require 'thread'
|
393
|
+
@cache_write_lock = Mutex.new
|
394
|
+
end
|
395
|
+
|
396
|
+
def get(app_uuid)
|
397
|
+
if !@cache[app_uuid] || @cache[app_uuid].expired?
|
398
|
+
# url-encode the app_uuid to prevent trickery like escaping upward with ../../ in a malicious
|
399
|
+
# app_uuid - probably not exploitable, but this is the right way to do it anyway.
|
400
|
+
# use UNRESERVED instead of UNSAFE (the default) as UNSAFE doesn't include /
|
401
|
+
url_encoded_app_uuid = URI.escape(app_uuid, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
|
402
|
+
begin
|
403
|
+
response = signed_mauth_connection.get("/mauth/#{@mauth_client.mauth_api_version}/security_tokens/#{url_encoded_app_uuid}.json")
|
404
|
+
rescue ::Faraday::Error::ConnectionFailed, ::Faraday::Error::TimeoutError
|
405
|
+
raise UnableToAuthenticateError, "mAuth service did not respond; received #{$!.class}: #{$!.message}"
|
406
|
+
end
|
407
|
+
if response.status == 200
|
408
|
+
begin
|
409
|
+
security_token = JSON.parse(response.body)
|
410
|
+
rescue JSON::ParserError
|
411
|
+
raise UnableToAuthenticateError, "mAuth service responded with unparseable json: #{response.body}\n#{$!.class}: #{$!.message}"
|
412
|
+
end
|
413
|
+
@cache_write_lock.synchronize do
|
414
|
+
@cache[app_uuid] = ExpirableSecurityToken.new(security_token, Time.now)
|
415
|
+
end
|
416
|
+
elsif response.status == 404
|
417
|
+
# signing with a key mAuth doesn't know about is considered inauthentic
|
418
|
+
raise InauthenticError, "mAuth service responded with 404 looking up public key for #{app_uuid}"
|
419
|
+
else
|
420
|
+
@mauth_client.send(:mauth_service_response_error, response)
|
421
|
+
end
|
422
|
+
end
|
423
|
+
@cache[app_uuid].security_token
|
424
|
+
end
|
425
|
+
|
426
|
+
private
|
427
|
+
|
428
|
+
def signed_mauth_connection
|
429
|
+
require 'faraday'
|
430
|
+
require 'mauth/faraday'
|
431
|
+
@mauth_client.faraday_options[:ssl] = { ca_path: @mauth_client.ssl_certs_path } if @mauth_client.ssl_certs_path
|
432
|
+
@signed_mauth_connection ||= ::Faraday.new(@mauth_client.mauth_baseurl, @mauth_client.faraday_options) do |builder|
|
433
|
+
builder.use MAuth::Faraday::MAuthClientUserAgent
|
434
|
+
builder.use MAuth::Faraday::RequestSigner, 'mauth_client' => @mauth_client
|
435
|
+
builder.adapter ::Faraday.default_adapter
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
# methods for remotely authenticating a request by sending it to the mauth service
|
442
|
+
module RemoteRequestAuthenticator
|
443
|
+
private
|
444
|
+
|
445
|
+
# takes an incoming request object (no support for responses currently), and errors if the
|
446
|
+
# object is not authentic according to its signature
|
447
|
+
def signature_valid!(object)
|
448
|
+
raise ArgumentError, "Remote Authenticator can only authenticate requests; received #{object.inspect}" unless object.is_a?(MAuth::Request)
|
449
|
+
authentication_ticket = {
|
450
|
+
'verb' => object.attributes_for_signing[:verb],
|
451
|
+
'app_uuid' => object.signature_app_uuid,
|
452
|
+
'client_signature' => object.signature,
|
453
|
+
'request_url' => object.attributes_for_signing[:request_url],
|
454
|
+
'request_time' => object.x_mws_time,
|
455
|
+
'b64encoded_body' => Base64.encode64(object.attributes_for_signing[:body] || '')
|
456
|
+
}
|
457
|
+
begin
|
458
|
+
response = mauth_connection.post("/mauth/#{mauth_api_version}/authentication_tickets.json", "authentication_ticket" => authentication_ticket)
|
459
|
+
rescue ::Faraday::Error::ConnectionFailed, ::Faraday::Error::TimeoutError
|
460
|
+
raise UnableToAuthenticateError, "mAuth service did not respond; received #{$!.class}: #{$!.message}"
|
461
|
+
end
|
462
|
+
if (200..299).cover?(response.status)
|
463
|
+
nil
|
464
|
+
elsif response.status == 412 || response.status == 404
|
465
|
+
# the mAuth service responds with 412 when the given request is not authentically signed.
|
466
|
+
# older versions of the mAuth service respond with 404 when the given app_uuid
|
467
|
+
# does not exist, which is also considered to not be authentically signed. newer
|
468
|
+
# versions of the service respond 412 in all cases, so the 404 check may be removed
|
469
|
+
# when the old version of the mAuth service is out of service.
|
470
|
+
raise InauthenticError, "The mAuth service responded with #{response.status}: #{response.body}"
|
471
|
+
else
|
472
|
+
mauth_service_response_error(response)
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
476
|
+
def mauth_connection
|
477
|
+
require 'faraday'
|
478
|
+
require 'faraday_middleware'
|
479
|
+
@mauth_connection ||= ::Faraday.new(mauth_baseurl, faraday_options) do |builder|
|
480
|
+
builder.use MAuth::Faraday::MAuthClientUserAgent
|
481
|
+
builder.use FaradayMiddleware::EncodeJson
|
482
|
+
builder.adapter ::Faraday.default_adapter
|
483
|
+
end
|
484
|
+
end
|
485
|
+
end
|
486
|
+
end
|
487
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
<%= warning.as_yaml_comment %>
|
2
|
+
|
3
|
+
MAUTH_CONF = MAuth::Client.default_config
|
4
|
+
require 'mauth/rack'
|
5
|
+
# ResponseSigner OPTIONAL; only use if you are registered in mauth service
|
6
|
+
Rails.application.config.middleware.insert_after Rack::Runtime, MAuth::Rack::ResponseSigner, MAUTH_CONF
|
7
|
+
if Rails.env.test? || Rails.env.development?
|
8
|
+
require 'mauth/fake/rack'
|
9
|
+
Rails.application.config.middleware.insert_after MAuth::Rack::ResponseSigner, MAuth::Rack::RequestAuthenticationFaker, MAUTH_CONF
|
10
|
+
else
|
11
|
+
Rails.application.config.middleware.insert_after MAuth::Rack::ResponseSigner, MAuth::Rack::RequestAuthenticatorNoAppStatus, MAUTH_CONF
|
12
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
<%= warning.as_yaml_comment %>
|
2
|
+
|
3
|
+
common: &common
|
4
|
+
mauth_baseurl: <%= configured.mauth_url! || 'http://localhost:7000' %>
|
5
|
+
mauth_api_version: v1
|
6
|
+
app_uuid: <%= configured.mauth_app_uuid! || 'fb17460e-9868-11e1-8399-0090f5ccb4d3' %>
|
7
|
+
private_key_file: config/mauth_key
|
8
|
+
|
9
|
+
production:
|
10
|
+
<<: *common
|
11
|
+
development:
|
12
|
+
<<: *common
|
13
|
+
test:
|
14
|
+
<<: *common
|
@@ -0,0 +1 @@
|
|
1
|
+
<%= ensure_is_private_key(configured.mauth_private_key! || generate_private_key.to_s) %>
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'dice_bag'
|
2
|
+
|
3
|
+
class MauthTemplate < DiceBag::AvailableTemplates
|
4
|
+
def templates
|
5
|
+
['mauth.yml.dice', 'mauth_key.dice'].map do |template|
|
6
|
+
File.join(File.dirname(__FILE__), template)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class MauthInitializerTemplate < DiceBag::AvailableTemplates
|
12
|
+
def templates_location
|
13
|
+
'config/initializers'
|
14
|
+
end
|
15
|
+
|
16
|
+
def templates
|
17
|
+
[File.join(File.dirname(__FILE__), 'mauth.rb.dice')] if Object.const_defined?('Rails')
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'mauth/rack'
|
2
|
+
|
3
|
+
module MAuth
|
4
|
+
module Rack
|
5
|
+
# This middleware bypasses actual authentication (it does not invoke mauth_client.authentic?). It
|
6
|
+
# instead uses a class attr method (is_authenic?) to determine if the request should be deemed authentic or not.
|
7
|
+
# Requests are authentic by default and RequestAuthenticationFaker.authentic = false must be called
|
8
|
+
# BEFORE EACH REQUEST in order to make a request inauthentic.
|
9
|
+
#
|
10
|
+
# This is for testing environments where you do not wish to rely on a mauth service for making requests.
|
11
|
+
#
|
12
|
+
# Note that if your application does not use env['mauth.app_uuid'] or env['mauth.authentic'] then it
|
13
|
+
# may be simpler to simply omit the request authentication middleware entirely in your test environment
|
14
|
+
# (rather than switching to this fake one), as all this does is add those keys to the request env.
|
15
|
+
class RequestAuthenticationFaker < MAuth::Rack::RequestAuthenticator
|
16
|
+
class << self
|
17
|
+
def is_authentic?
|
18
|
+
@is_authentic.nil? ? true : @is_authentic
|
19
|
+
end
|
20
|
+
|
21
|
+
def authentic=(is_auth = true)
|
22
|
+
@is_authentic = is_auth
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def call(env)
|
27
|
+
retval = if should_authenticate?(env)
|
28
|
+
mauth_request = MAuth::Rack::Request.new(env)
|
29
|
+
if self.class.is_authentic?
|
30
|
+
@app.call(env.merge('mauth.app_uuid' => mauth_request.signature_app_uuid, 'mauth.authentic' => true))
|
31
|
+
else
|
32
|
+
response_for_inauthentic_request(env)
|
33
|
+
end
|
34
|
+
else
|
35
|
+
@app.call(env)
|
36
|
+
end
|
37
|
+
|
38
|
+
# ensure that the next request is marked authenic unless the consumer of this middleware explicitly deems otherwise
|
39
|
+
self.class.authentic = true
|
40
|
+
|
41
|
+
retval
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'mauth/middleware'
|
2
|
+
require 'mauth/request_and_response'
|
3
|
+
|
4
|
+
Faraday::Request.register_middleware(mauth_request_signer: proc { MAuth::Faraday::RequestSigner })
|
5
|
+
Faraday::Response.register_middleware(mauth_response_authenticator: proc { MAuth::Faraday::ResponseAuthenticator })
|
6
|
+
|
7
|
+
module MAuth
|
8
|
+
module Faraday
|
9
|
+
# faraday middleware to sign outgoing requests
|
10
|
+
class RequestSigner < MAuth::Middleware
|
11
|
+
def call(request_env)
|
12
|
+
signed_request_env = mauth_client.signed(MAuth::Faraday::Request.new(request_env)).request_env
|
13
|
+
@app.call(signed_request_env)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# faraday middleware to authenticate incoming responses
|
18
|
+
class ResponseAuthenticator < MAuth::Middleware
|
19
|
+
def call(request_env)
|
20
|
+
@app.call(request_env).on_complete do |response_env|
|
21
|
+
mauth_response = MAuth::Faraday::Response.new(response_env)
|
22
|
+
mauth_client.authenticate!(mauth_response) # raises MAuth::InauthenticError when inauthentic
|
23
|
+
response_env['mauth.app_uuid'] = mauth_response.signature_app_uuid
|
24
|
+
response_env['mauth.authentic'] = true
|
25
|
+
response_env
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# representation of a request (outgoing) composed from a Faraday request env which can be
|
31
|
+
# passed to a Mauth::Client for signing
|
32
|
+
class Request < MAuth::Request
|
33
|
+
attr_reader :request_env
|
34
|
+
def initialize(request_env)
|
35
|
+
@request_env = request_env
|
36
|
+
end
|
37
|
+
|
38
|
+
def attributes_for_signing
|
39
|
+
request_url = @request_env[:url].path
|
40
|
+
request_url = '/' if request_url.empty?
|
41
|
+
@attributes_for_signing ||= { verb: @request_env[:method].to_s.upcase, request_url: request_url, body: @request_env[:body] }
|
42
|
+
end
|
43
|
+
|
44
|
+
# takes a Hash of headers; returns an instance of this class whose
|
45
|
+
# headers have been merged with the argument headers
|
46
|
+
def merge_headers(headers)
|
47
|
+
self.class.new(@request_env.merge(request_headers: @request_env[:request_headers].merge(headers)))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# representation of a Response (incoming) composed from a Faraday response env which can be
|
52
|
+
# passed to a Mauth::Client for authentication
|
53
|
+
class Response < MAuth::Response
|
54
|
+
include Signed
|
55
|
+
attr_reader :response_env
|
56
|
+
def initialize(response_env)
|
57
|
+
@response_env = response_env
|
58
|
+
end
|
59
|
+
|
60
|
+
def attributes_for_signing
|
61
|
+
@attributes_for_signing ||= { status_code: response_env[:status], body: response_env[:body] }
|
62
|
+
end
|
63
|
+
|
64
|
+
def x_mws_time
|
65
|
+
@response_env[:response_headers]['x-mws-time']
|
66
|
+
end
|
67
|
+
|
68
|
+
def x_mws_authentication
|
69
|
+
@response_env[:response_headers]['x-mws-authentication']
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# add MAuth-Client's user-agent to a request
|
74
|
+
class MAuthClientUserAgent
|
75
|
+
def initialize(app, agent_base = "Mauth-Client")
|
76
|
+
@app = app
|
77
|
+
@agent_base = agent_base
|
78
|
+
end
|
79
|
+
|
80
|
+
def call(request_env)
|
81
|
+
agent = "#{@agent_base} (MAuth-Client: #{MAuth::VERSION}; Ruby: #{RUBY_VERSION}; platform: #{RUBY_PLATFORM})"
|
82
|
+
request_env[:request_headers]['User-Agent'] ||= agent
|
83
|
+
@app.call(request_env)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|