mauth-client 4.1.1 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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