mauth-client 4.2.0 → 5.0.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.
@@ -0,0 +1,118 @@
1
+ # methods common to RemoteRequestAuthenticator and LocalAuthenticator
2
+
3
+ module MAuth
4
+ class Client
5
+ module AuthenticatorBase
6
+ ALLOWED_DRIFT_SECONDS = 300
7
+
8
+ # takes an incoming request or response object, and returns whether
9
+ # the object is authentic according to its signature.
10
+ def authentic?(object)
11
+ log_authentication_request(object)
12
+ begin
13
+ authenticate!(object)
14
+ true
15
+ rescue InauthenticError, MAuthNotPresent, MissingV2Error
16
+ false
17
+ end
18
+ end
19
+
20
+ # raises InauthenticError unless the given object is authentic. Will only
21
+ # authenticate with v2 if the environment variable V2_ONLY_AUTHENTICATE
22
+ # is set. Otherwise will authenticate with only the highest protocol version present
23
+ def authenticate!(object)
24
+ if object.protocol_version == 2
25
+ authenticate_v2!(object)
26
+ elsif object.protocol_version == 1
27
+ if v2_only_authenticate?
28
+ # If v2 is required but not present and v1 is present we raise MissingV2Error
29
+ msg = 'This service requires mAuth v2 mcc-authentication header but only v1 x-mws-authentication is present'
30
+ logger.error(msg)
31
+ raise MissingV2Error, msg
32
+ end
33
+
34
+ authenticate_v1!(object)
35
+ else
36
+ sub_str = v2_only_authenticate? ? '' : 'X-MWS-Authentication header is blank, '
37
+ msg = "Authentication Failed. No mAuth signature present; #{sub_str}MCC-Authentication header is blank."
38
+ logger.warn("mAuth signature not present on #{object.class}. Exception: #{msg}")
39
+ raise MAuthNotPresent, msg
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # Note: This log is likely consumed downstream and the contents SHOULD NOT
46
+ # be changed without a thorough review of downstream consumers.
47
+ def log_authentication_request(object)
48
+ object_app_uuid = object.signature_app_uuid || '[none provided]'
49
+ object_token = object.signature_token || '[none provided]'
50
+ logger.info(
51
+ "Mauth-client attempting to authenticate request from app with mauth" \
52
+ " app uuid #{object_app_uuid} to app with mauth app uuid #{client_app_uuid}" \
53
+ " using version #{object_token}."
54
+ )
55
+ end
56
+
57
+ def log_inauthentic(object, message)
58
+ logger.error("mAuth signature authentication failed for #{object.class}. Exception: #{message}")
59
+ end
60
+
61
+ def time_within_valid_range!(object, time_signed, now = Time.now)
62
+ return if (-ALLOWED_DRIFT_SECONDS..ALLOWED_DRIFT_SECONDS).cover?(now.to_i - time_signed)
63
+
64
+ msg = "Time verification failed. #{time_signed} not within #{ALLOWED_DRIFT_SECONDS} of #{now}"
65
+ log_inauthentic(object, msg)
66
+ raise InauthenticError, msg
67
+ end
68
+
69
+ # V1 helpers
70
+ def authenticate_v1!(object)
71
+ time_valid_v1!(object)
72
+ token_valid_v1!(object)
73
+ signature_valid_v1!(object)
74
+ end
75
+
76
+ def time_valid_v1!(object)
77
+ if object.x_mws_time.nil?
78
+ msg = 'Time verification failed. No x-mws-time present.'
79
+ log_inauthentic(object, msg)
80
+ raise InauthenticError, msg
81
+ end
82
+ time_within_valid_range!(object, object.x_mws_time.to_i)
83
+ end
84
+
85
+ def token_valid_v1!(object)
86
+ return if object.signature_token == MWS_TOKEN
87
+
88
+ msg = "Token verification failed. Expected #{MWS_TOKEN}; token was #{object.signature_token}"
89
+ log_inauthentic(object, msg)
90
+ raise InauthenticError, msg
91
+ end
92
+
93
+ # V2 helpers
94
+ def authenticate_v2!(object)
95
+ time_valid_v2!(object)
96
+ token_valid_v2!(object)
97
+ signature_valid_v2!(object)
98
+ end
99
+
100
+ def time_valid_v2!(object)
101
+ if object.mcc_time.nil?
102
+ msg = 'Time verification failed. No MCC-Time present.'
103
+ log_inauthentic(object, msg)
104
+ raise InauthenticError, msg
105
+ end
106
+ time_within_valid_range!(object, object.mcc_time.to_i)
107
+ end
108
+
109
+ def token_valid_v2!(object)
110
+ return if object.signature_token == MWSV2_TOKEN
111
+
112
+ msg = "Token verification failed. Expected #{MWSV2_TOKEN}; token was #{object.signature_token}"
113
+ log_inauthentic(object, msg)
114
+ raise InauthenticError, msg
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,137 @@
1
+ require 'mauth/client/security_token_cacher'
2
+ require 'mauth/client/signer'
3
+ require 'openssl'
4
+
5
+ # methods to verify the authenticity of signed requests and responses locally, retrieving
6
+ # public keys from the mAuth service as needed
7
+
8
+ module MAuth
9
+ class Client
10
+ module LocalAuthenticator
11
+ private
12
+
13
+ def signature_valid_v1!(object)
14
+ # We are in an unfortunate situation in which Euresource is percent-encoding parts of paths, but not
15
+ # all of them. In particular, Euresource is percent-encoding all special characters save for '/'.
16
+ # Also, unfortunately, Nginx unencodes URIs before sending them off to served applications, though
17
+ # other web servers (particularly those we typically use for local testing) do not. The various forms
18
+ # of the expected string to sign are meant to cover the main cases.
19
+ # TODO: Revisit and simplify this unfortunate situation.
20
+
21
+ original_request_uri = object.attributes_for_signing[:request_url]
22
+
23
+ # craft an expected string-to-sign without doing any percent-encoding
24
+ expected_no_reencoding = object.string_to_sign_v1(time: object.x_mws_time, app_uuid: object.signature_app_uuid)
25
+
26
+ # do a simple percent reencoding variant of the path
27
+ object.attributes_for_signing[:request_url] = CGI.escape(original_request_uri.to_s)
28
+ expected_for_percent_reencoding = object.string_to_sign_v1(time: object.x_mws_time, app_uuid: object.signature_app_uuid)
29
+
30
+ # do a moderately complex Euresource-style reencoding of the path
31
+ object.attributes_for_signing[:request_url] = euresource_escape(original_request_uri.to_s)
32
+ expected_euresource_style_reencoding = object.string_to_sign_v1(time: object.x_mws_time, app_uuid: object.signature_app_uuid)
33
+
34
+ # reset the object original request_uri, just in case we need it again
35
+ object.attributes_for_signing[:request_url] = original_request_uri
36
+
37
+ begin
38
+ pubkey = OpenSSL::PKey::RSA.new(retrieve_public_key(object.signature_app_uuid))
39
+ actual = pubkey.public_decrypt(Base64.decode64(object.signature))
40
+ rescue OpenSSL::PKey::PKeyError => e
41
+ msg = "Public key decryption of signature failed! #{e.class}: #{e.message}"
42
+ log_inauthentic(object, msg)
43
+ raise InauthenticError, msg
44
+ end
45
+
46
+ unless verify_signature_v1!(actual, expected_no_reencoding) ||
47
+ verify_signature_v1!(actual, expected_euresource_style_reencoding) ||
48
+ verify_signature_v1!(actual, expected_for_percent_reencoding)
49
+ msg = "Signature verification failed for #{object.class}"
50
+ log_inauthentic(object, msg)
51
+ raise InauthenticError, msg
52
+ end
53
+ end
54
+
55
+ def verify_signature_v1!(actual, expected_str_to_sign)
56
+ actual == Digest::SHA512.hexdigest(expected_str_to_sign)
57
+ end
58
+
59
+ def signature_valid_v2!(object)
60
+ # We are in an unfortunate situation in which Euresource is percent-encoding parts of paths, but not
61
+ # all of them. In particular, Euresource is percent-encoding all special characters save for '/'.
62
+ # Also, unfortunately, Nginx unencodes URIs before sending them off to served applications, though
63
+ # other web servers (particularly those we typically use for local testing) do not. The various forms
64
+ # of the expected string to sign are meant to cover the main cases.
65
+ # TODO: Revisit and simplify this unfortunate situation.
66
+
67
+ original_request_uri = object.attributes_for_signing[:request_url]
68
+ original_query_string = object.attributes_for_signing[:query_string]
69
+
70
+ # craft an expected string-to-sign without doing any percent-encoding
71
+ expected_no_reencoding = object.string_to_sign_v2(
72
+ time: object.mcc_time,
73
+ app_uuid: object.signature_app_uuid
74
+ )
75
+
76
+ # do a simple percent reencoding variant of the path
77
+ expected_for_percent_reencoding = object.string_to_sign_v2(
78
+ time: object.mcc_time,
79
+ app_uuid: object.signature_app_uuid,
80
+ request_url: CGI.escape(original_request_uri.to_s),
81
+ query_string: CGI.escape(original_query_string.to_s)
82
+ )
83
+
84
+ # do a moderately complex Euresource-style reencoding of the path
85
+ expected_euresource_style_reencoding = object.string_to_sign_v2(
86
+ time: object.mcc_time,
87
+ app_uuid: object.signature_app_uuid,
88
+ request_url: euresource_escape(original_request_uri.to_s),
89
+ query_string: euresource_escape(original_query_string.to_s)
90
+ )
91
+
92
+ pubkey = OpenSSL::PKey::RSA.new(retrieve_public_key(object.signature_app_uuid))
93
+ actual = Base64.decode64(object.signature)
94
+
95
+ unless verify_signature_v2!(object, actual, pubkey, expected_no_reencoding) ||
96
+ verify_signature_v2!(object, actual, pubkey, expected_euresource_style_reencoding) ||
97
+ verify_signature_v2!(object, actual, pubkey, expected_for_percent_reencoding)
98
+ msg = "Signature inauthentic for #{object.class}"
99
+ log_inauthentic(object, msg)
100
+ raise InauthenticError, msg
101
+ end
102
+ end
103
+
104
+ def verify_signature_v2!(object, actual, pubkey, expected_str_to_sign)
105
+ pubkey.verify(
106
+ MAuth::Client::SIGNING_DIGEST,
107
+ actual,
108
+ expected_str_to_sign
109
+ )
110
+ rescue OpenSSL::PKey::PKeyError => e
111
+ msg = "RSA verification of signature failed! #{e.class}: #{e.message}"
112
+ log_inauthentic(object, msg)
113
+ raise InauthenticError, msg
114
+ end
115
+
116
+ # Note: RFC 3986 (https://www.ietf.org/rfc/rfc3986.txt) reserves the forward slash "/"
117
+ # and number sign "#" as component delimiters. Since these are valid URI components,
118
+ # they are decoded back into characters here to avoid signature invalidation
119
+ def euresource_escape(str)
120
+ CGI.escape(str).gsub(/%2F|%23/, '%2F' => '/', '%23' => '#')
121
+ end
122
+
123
+ def retrieve_public_key(app_uuid)
124
+ retrieve_security_token(app_uuid)['security_token']['public_key_str']
125
+ end
126
+
127
+ def retrieve_security_token(app_uuid)
128
+ security_token_cacher.get(app_uuid)
129
+ end
130
+
131
+ def security_token_cacher
132
+ @security_token_cacher ||= SecurityTokenCacher.new(self)
133
+ end
134
+
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,75 @@
1
+ # methods for remotely authenticating a request by sending it to the mauth service
2
+
3
+ module MAuth
4
+ class Client
5
+ module RemoteRequestAuthenticator
6
+ private
7
+
8
+ # takes an incoming request object (no support for responses currently), and errors if the
9
+ # object is not authentic according to its signature
10
+ def signature_valid_v1!(object)
11
+ raise ArgumentError, "Remote Authenticator can only authenticate requests; received #{object.inspect}" unless object.is_a?(MAuth::Request)
12
+ authentication_ticket = {
13
+ 'verb' => object.attributes_for_signing[:verb],
14
+ 'app_uuid' => object.signature_app_uuid,
15
+ 'client_signature' => object.signature,
16
+ 'request_url' => object.attributes_for_signing[:request_url],
17
+ 'request_time' => object.x_mws_time,
18
+ 'b64encoded_body' => Base64.encode64(object.attributes_for_signing[:body] || '')
19
+ }
20
+ make_mauth_request(authentication_ticket)
21
+ end
22
+
23
+ def signature_valid_v2!(object)
24
+ unless object.is_a?(MAuth::Request)
25
+ msg = "Remote Authenticator can only authenticate requests; received #{object.inspect}"
26
+ raise ArgumentError, msg
27
+ end
28
+
29
+ authentication_ticket = {
30
+ verb: object.attributes_for_signing[:verb],
31
+ app_uuid: object.signature_app_uuid,
32
+ client_signature: object.signature,
33
+ request_url: object.attributes_for_signing[:request_url],
34
+ request_time: object.mcc_time,
35
+ b64encoded_body: Base64.encode64(object.attributes_for_signing[:body] || ''),
36
+ query_string: object.attributes_for_signing[:query_string],
37
+ token: object.signature_token
38
+ }
39
+ make_mauth_request(authentication_ticket)
40
+ end
41
+
42
+ def make_mauth_request(authentication_ticket)
43
+ begin
44
+ response = mauth_connection.post("/mauth/#{mauth_api_version}/authentication_tickets.json", 'authentication_ticket' => authentication_ticket)
45
+ rescue ::Faraday::Error::ConnectionFailed, ::Faraday::Error::TimeoutError => e
46
+ msg = "mAuth service did not respond; received #{e.class}: #{e.message}"
47
+ logger.error("Unable to authenticate with MAuth. Exception #{msg}")
48
+ raise UnableToAuthenticateError, msg
49
+ end
50
+ if (200..299).cover?(response.status)
51
+ nil
52
+ elsif response.status == 412 || response.status == 404
53
+ # the mAuth service responds with 412 when the given request is not authentically signed.
54
+ # older versions of the mAuth service respond with 404 when the given app_uuid
55
+ # does not exist, which is also considered to not be authentically signed. newer
56
+ # versions of the service respond 412 in all cases, so the 404 check may be removed
57
+ # when the old version of the mAuth service is out of service.
58
+ raise InauthenticError, "The mAuth service responded with #{response.status}: #{response.body}"
59
+ else
60
+ mauth_service_response_error(response)
61
+ end
62
+ end
63
+
64
+ def mauth_connection
65
+ require 'faraday'
66
+ require 'faraday_middleware'
67
+ @mauth_connection ||= ::Faraday.new(mauth_baseurl, faraday_options) do |builder|
68
+ builder.use MAuth::Faraday::MAuthClientUserAgent
69
+ builder.use FaradayMiddleware::EncodeJson
70
+ builder.adapter ::Faraday.default_adapter
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,71 @@
1
+ module MAuth
2
+ class Client
3
+ module LocalAuthenticator
4
+ class SecurityTokenCacher
5
+
6
+ class ExpirableSecurityToken < Struct.new(:security_token, :create_time)
7
+ CACHE_LIFE = 60
8
+ def expired?
9
+ create_time + CACHE_LIFE < Time.now
10
+ end
11
+ end
12
+
13
+ def initialize(mauth_client)
14
+ @mauth_client = mauth_client
15
+ # TODO: should this be UnableToSignError?
16
+ @mauth_client.assert_private_key(UnableToAuthenticateError.new("Cannot fetch public keys from mAuth service without a private key!"))
17
+ @cache = {}
18
+ require 'thread'
19
+ @cache_write_lock = Mutex.new
20
+ end
21
+
22
+ def get(app_uuid)
23
+ if !@cache[app_uuid] || @cache[app_uuid].expired?
24
+ # url-encode the app_uuid to prevent trickery like escaping upward with ../../ in a malicious
25
+ # app_uuid - probably not exploitable, but this is the right way to do it anyway.
26
+ # use UNRESERVED instead of UNSAFE (the default) as UNSAFE doesn't include /
27
+ url_encoded_app_uuid = URI.escape(app_uuid, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
28
+ begin
29
+ response = signed_mauth_connection.get("/mauth/#{@mauth_client.mauth_api_version}/security_tokens/#{url_encoded_app_uuid}.json")
30
+ rescue ::Faraday::Error::ConnectionFailed, ::Faraday::Error::TimeoutError => e
31
+ msg = "mAuth service did not respond; received #{e.class}: #{e.message}"
32
+ @mauth_client.logger.error("Unable to authenticate with MAuth. Exception #{msg}")
33
+ raise UnableToAuthenticateError, msg
34
+ end
35
+ if response.status == 200
36
+ begin
37
+ security_token = JSON.parse(response.body)
38
+ rescue JSON::ParserError => e
39
+ msg = "mAuth service responded with unparseable json: #{response.body}\n#{e.class}: #{e.message}"
40
+ @mauth_client.logger.error("Unable to authenticate with MAuth. Exception #{msg}")
41
+ raise UnableToAuthenticateError, msg
42
+ end
43
+ @cache_write_lock.synchronize do
44
+ @cache[app_uuid] = ExpirableSecurityToken.new(security_token, Time.now)
45
+ end
46
+ elsif response.status == 404
47
+ # signing with a key mAuth doesn't know about is considered inauthentic
48
+ raise InauthenticError, "mAuth service responded with 404 looking up public key for #{app_uuid}"
49
+ else
50
+ @mauth_client.send(:mauth_service_response_error, response)
51
+ end
52
+ end
53
+ @cache[app_uuid].security_token
54
+ end
55
+
56
+ private
57
+
58
+ def signed_mauth_connection
59
+ require 'faraday'
60
+ require 'mauth/faraday'
61
+ @mauth_client.faraday_options[:ssl] = { ca_path: @mauth_client.ssl_certs_path } if @mauth_client.ssl_certs_path
62
+ @signed_mauth_connection ||= ::Faraday.new(@mauth_client.mauth_baseurl, @mauth_client.faraday_options) do |builder|
63
+ builder.use MAuth::Faraday::MAuthClientUserAgent
64
+ builder.use MAuth::Faraday::RequestSigner, 'mauth_client' => @mauth_client
65
+ builder.adapter ::Faraday.default_adapter
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ require 'openssl'
2
+ require 'mauth/errors'
3
+
4
+ # methods to sign requests and responses. part of MAuth::Client
5
+
6
+ module MAuth
7
+ class Client
8
+ SIGNING_DIGEST = OpenSSL::Digest::SHA512.new
9
+
10
+ module Signer
11
+ UNABLE_TO_SIGN_ERR = UnableToSignError.new('mAuth client cannot sign without a private key!')
12
+
13
+ # takes an outgoing request or response object, and returns an object of the same class
14
+ # whose headers are updated to include mauth's signature headers
15
+ def signed(object, attributes = {})
16
+ object.merge_headers(signed_headers(object, attributes))
17
+ end
18
+
19
+ # signs with v1 only. used when signing responses to v1 requests.
20
+ def signed_v1(object, attributes = {})
21
+ object.merge_headers(signed_headers_v1(object, attributes))
22
+ end
23
+
24
+ def signed_v2(object, attributes = {})
25
+ object.merge_headers(signed_headers_v2(object, attributes))
26
+ end
27
+
28
+ # takes a signable object (outgoing request or response). returns a hash of headers to be
29
+ # applied to the object which comprises its signature.
30
+ def signed_headers(object, attributes = {})
31
+ if v2_only_sign_requests?
32
+ signed_headers_v2(object, attributes)
33
+ else # by default sign with both the v1 and v2 protocol
34
+ signed_headers_v1(object, attributes).merge(signed_headers_v2(object, attributes))
35
+ end
36
+ end
37
+
38
+ def signed_headers_v1(object, attributes = {})
39
+ attributes = { time: Time.now.to_i.to_s, app_uuid: client_app_uuid }.merge(attributes)
40
+ string_to_sign = object.string_to_sign_v1(attributes)
41
+ signature = self.signature_v1(string_to_sign)
42
+ { 'X-MWS-Authentication' => "#{MWS_TOKEN} #{client_app_uuid}:#{signature}", 'X-MWS-Time' => attributes[:time] }
43
+ end
44
+
45
+ def signed_headers_v2(object, attributes = {})
46
+ attributes = { time: Time.now.to_i.to_s, app_uuid: client_app_uuid }.merge(attributes)
47
+ string_to_sign = object.string_to_sign_v2(attributes)
48
+ signature = self.signature_v2(string_to_sign)
49
+ {
50
+ 'MCC-Authentication' => "#{MWSV2_TOKEN} #{client_app_uuid}:#{signature}#{AUTH_HEADER_DELIMITER}",
51
+ 'MCC-Time' => attributes[:time]
52
+ }
53
+ end
54
+
55
+ def signature_v1(string_to_sign)
56
+ assert_private_key(UNABLE_TO_SIGN_ERR)
57
+ hashed_string_to_sign = Digest::SHA512.hexdigest(string_to_sign)
58
+ Base64.encode64(private_key.private_encrypt(hashed_string_to_sign)).delete("\n")
59
+ end
60
+
61
+ def signature_v2(string_to_sign)
62
+ assert_private_key(UNABLE_TO_SIGN_ERR)
63
+ Base64.encode64(private_key.sign(SIGNING_DIGEST, string_to_sign)).delete("\n")
64
+ end
65
+ end
66
+ end
67
+ end