|
14 | 14 |
|
15 | 15 | """MONGODB-AWS Authentication helpers."""
|
16 | 16 |
|
17 |
| -import os |
18 |
| - |
19 | 17 | try:
|
20 |
| - |
21 |
| - from botocore.auth import SigV4Auth |
22 |
| - from botocore.awsrequest import AWSRequest |
23 |
| - from botocore.credentials import Credentials |
24 |
| - |
25 |
| - import requests |
26 |
| - |
| 18 | + import pymongo_auth_aws |
| 19 | + from pymongo_auth_aws import (AwsCredential, |
| 20 | + AwsSaslContext, |
| 21 | + PyMongoAuthAwsError) |
27 | 22 | _HAVE_MONGODB_AWS = True
|
28 | 23 | except ImportError:
|
29 | 24 | _HAVE_MONGODB_AWS = False
|
30 | 25 |
|
31 | 26 | import bson
|
32 |
| - |
33 |
| - |
34 |
| -from base64 import standard_b64encode |
35 |
| -from collections import namedtuple |
36 |
| - |
37 | 27 | from bson.binary import Binary
|
38 | 28 | from bson.son import SON
|
39 | 29 | from pymongo.errors import ConfigurationError, OperationFailure
|
40 | 30 |
|
41 | 31 |
|
42 |
| -_AWS_REL_URI = 'http://169.254.170.2/' |
43 |
| -_AWS_EC2_URI = 'http://169.254.169.254/' |
44 |
| -_AWS_EC2_PATH = 'latest/meta-data/iam/security-credentials/' |
45 |
| -_AWS_HTTP_TIMEOUT = 10 |
46 |
| - |
47 |
| - |
48 |
| -_AWSCredential = namedtuple('_AWSCredential', |
49 |
| - ['username', 'password', 'token']) |
50 |
| -"""MONGODB-AWS credentials.""" |
51 |
| - |
52 |
| - |
53 |
| -def _aws_temp_credentials(): |
54 |
| - """Construct temporary MONGODB-AWS credentials.""" |
55 |
| - access_key = os.environ.get('AWS_ACCESS_KEY_ID') |
56 |
| - secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY') |
57 |
| - if access_key and secret_key: |
58 |
| - return _AWSCredential( |
59 |
| - access_key, secret_key, os.environ.get('AWS_SESSION_TOKEN')) |
60 |
| - # If the environment variable |
61 |
| - # AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set then drivers MUST |
62 |
| - # assume that it was set by an AWS ECS agent and use the URI |
63 |
| - # http://169.254.170.2/$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI to |
64 |
| - # obtain temporary credentials. |
65 |
| - relative_uri = os.environ.get('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI') |
66 |
| - if relative_uri is not None: |
67 |
| - try: |
68 |
| - res = requests.get(_AWS_REL_URI+relative_uri, |
69 |
| - timeout=_AWS_HTTP_TIMEOUT) |
70 |
| - res_json = res.json() |
71 |
| - except (ValueError, requests.exceptions.RequestException): |
72 |
| - raise OperationFailure( |
73 |
| - 'temporary MONGODB-AWS credentials could not be obtained') |
74 |
| - else: |
75 |
| - # If the environment variable AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is |
76 |
| - # not set drivers MUST assume we are on an EC2 instance and use the |
77 |
| - # endpoint |
78 |
| - # http://169.254.169.254/latest/meta-data/iam/security-credentials |
79 |
| - # /<role-name> |
80 |
| - # whereas role-name can be obtained from querying the URI |
81 |
| - # http://169.254.169.254/latest/meta-data/iam/security-credentials/. |
82 |
| - try: |
83 |
| - # Get token |
84 |
| - headers = {'X-aws-ec2-metadata-token-ttl-seconds': "30"} |
85 |
| - res = requests.post(_AWS_EC2_URI+'latest/api/token', |
86 |
| - headers=headers, timeout=_AWS_HTTP_TIMEOUT) |
87 |
| - token = res.content |
88 |
| - # Get role name |
89 |
| - headers = {'X-aws-ec2-metadata-token': token} |
90 |
| - res = requests.get(_AWS_EC2_URI+_AWS_EC2_PATH, headers=headers, |
91 |
| - timeout=_AWS_HTTP_TIMEOUT) |
92 |
| - role = res.text |
93 |
| - # Get temp creds |
94 |
| - res = requests.get(_AWS_EC2_URI+_AWS_EC2_PATH+role, |
95 |
| - headers=headers, timeout=_AWS_HTTP_TIMEOUT) |
96 |
| - res_json = res.json() |
97 |
| - except (ValueError, requests.exceptions.RequestException): |
98 |
| - raise OperationFailure( |
99 |
| - 'temporary MONGODB-AWS credentials could not be obtained') |
100 |
| - |
101 |
| - try: |
102 |
| - temp_user = res_json['AccessKeyId'] |
103 |
| - temp_password = res_json['SecretAccessKey'] |
104 |
| - token = res_json['Token'] |
105 |
| - except KeyError: |
106 |
| - # If temporary credentials cannot be obtained then drivers MUST |
107 |
| - # fail authentication and raise an error. |
108 |
| - raise OperationFailure( |
109 |
| - 'temporary MONGODB-AWS credentials could not be obtained') |
110 |
| - |
111 |
| - return _AWSCredential(temp_user, temp_password, token) |
112 |
| - |
113 |
| - |
114 |
| -_AWS4_HMAC_SHA256 = 'AWS4-HMAC-SHA256' |
115 |
| -_AWS_SERVICE = 'sts' |
116 |
| - |
| 32 | +class _AwsSaslContext(AwsSaslContext): |
| 33 | + # Dependency injection: |
| 34 | + def binary_type(self): |
| 35 | + """Return the bson.binary.Binary type.""" |
| 36 | + return Binary |
117 | 37 |
|
118 |
| -def _get_region(sts_host): |
119 |
| - """""" |
120 |
| - parts = sts_host.split('.') |
121 |
| - if len(parts) == 1 or sts_host == 'sts.amazonaws.com': |
122 |
| - return 'us-east-1' # Default |
| 38 | + def bson_encode(self, doc): |
| 39 | + """Encode a dictionary to BSON.""" |
| 40 | + return bson.encode(doc) |
123 | 41 |
|
124 |
| - if len(parts) > 2 or not all(parts): |
125 |
| - raise OperationFailure("Server returned an invalid sts host") |
| 42 | + def bson_decode(self, data): |
| 43 | + """Decode BSON to a dictionary.""" |
| 44 | + return bson.decode(data) |
126 | 45 |
|
127 |
| - return parts[1] |
128 |
| - |
129 |
| - |
130 |
| -def _aws_auth_header(credentials, server_nonce, sts_host): |
131 |
| - """Signature Version 4 Signing Process to construct the authorization header |
132 |
| - """ |
133 |
| - region = _get_region(sts_host) |
134 | 46 |
|
135 |
| - request_parameters = 'Action=GetCallerIdentity&Version=2011-06-15' |
136 |
| - encoded_nonce = standard_b64encode(server_nonce).decode('utf8') |
137 |
| - request_headers = { |
138 |
| - 'Content-Type': 'application/x-www-form-urlencoded', |
139 |
| - 'Content-Length': str(len(request_parameters)), |
140 |
| - 'Host': sts_host, |
141 |
| - 'X-MongoDB-Server-Nonce': encoded_nonce, |
142 |
| - 'X-MongoDB-GS2-CB-Flag': 'n', |
143 |
| - } |
144 |
| - request = AWSRequest(method="POST", url="/", data=request_parameters, |
145 |
| - headers=request_headers) |
146 |
| - boto_creds = Credentials(credentials.username, credentials.password, |
147 |
| - token=credentials.token) |
148 |
| - auth = SigV4Auth(boto_creds, "sts", region) |
149 |
| - auth.add_auth(request) |
150 |
| - final = { |
151 |
| - 'a': request.headers['Authorization'], |
152 |
| - 'd': request.headers['X-Amz-Date'] |
153 |
| - } |
154 |
| - if credentials.token: |
155 |
| - final['t'] = credentials.token |
156 |
| - return final |
157 |
| - |
158 |
| - |
159 |
| -def _auth_aws(credentials, sock_info): |
| 47 | +def _authenticate_aws(credentials, sock_info): |
160 | 48 | """Authenticate using MONGODB-AWS.
|
161 | 49 | """
|
162 | 50 | if not _HAVE_MONGODB_AWS:
|
163 | 51 | raise ConfigurationError(
|
164 |
| - "MONGODB-AWS authentication requires botocore and requests: " |
165 |
| - "install these libraries with: " |
166 |
| - "python -m pip install 'pymongo[aws]'") |
| 52 | + "MONGODB-AWS authentication requires pymongo-auth-aws: " |
| 53 | + "install with: python -m pip install 'pymongo[aws]'") |
167 | 54 |
|
168 | 55 | if sock_info.max_wire_version < 9:
|
169 | 56 | raise ConfigurationError(
|
170 | 57 | "MONGODB-AWS authentication requires MongoDB version 4.4 or later")
|
171 | 58 |
|
172 |
| - # If a username and password are not provided, drivers MUST query |
173 |
| - # a link-local AWS address for temporary credentials. |
174 |
| - if credentials.username is None: |
175 |
| - credentials = _aws_temp_credentials() |
176 |
| - |
177 |
| - # Client first. |
178 |
| - client_nonce = os.urandom(32) |
179 |
| - payload = {'r': Binary(client_nonce), 'p': 110} |
180 |
| - client_first = SON([('saslStart', 1), |
181 |
| - ('mechanism', 'MONGODB-AWS'), |
182 |
| - ('payload', Binary(bson.encode(payload)))]) |
183 |
| - server_first = sock_info.command('$external', client_first) |
184 |
| - |
185 |
| - server_payload = bson.decode(server_first['payload']) |
186 |
| - server_nonce = server_payload['s'] |
187 |
| - if len(server_nonce) != 64 or not server_nonce.startswith(client_nonce): |
188 |
| - raise OperationFailure("Server returned an invalid nonce.") |
189 |
| - sts_host = server_payload['h'] |
190 |
| - if len(sts_host) < 1 or len(sts_host) > 255 or '..' in sts_host: |
191 |
| - # Drivers must also validate that the host is greater than 0 and less |
192 |
| - # than or equal to 255 bytes per RFC 1035. |
193 |
| - raise OperationFailure("Server returned an invalid sts host.") |
194 |
| - |
195 |
| - payload = _aws_auth_header(credentials, server_nonce, sts_host) |
196 |
| - client_second = SON([('saslContinue', 1), |
197 |
| - ('conversationId', server_first['conversationId']), |
198 |
| - ('payload', Binary(bson.encode(payload)))]) |
199 |
| - res = sock_info.command('$external', client_second) |
200 |
| - if not res['done']: |
201 |
| - raise OperationFailure('MONGODB-AWS conversation failed to complete.') |
| 59 | + try: |
| 60 | + ctx = _AwsSaslContext(AwsCredential( |
| 61 | + credentials.username, credentials.password, |
| 62 | + credentials.mechanism_properties.aws_session_token)) |
| 63 | + client_payload = ctx.step(None) |
| 64 | + client_first = SON([('saslStart', 1), |
| 65 | + ('mechanism', 'MONGODB-AWS'), |
| 66 | + ('payload', client_payload)]) |
| 67 | + server_first = sock_info.command('$external', client_first) |
| 68 | + res = server_first |
| 69 | + # Limit how many times we loop to catch protocol / library issues |
| 70 | + for _ in range(10): |
| 71 | + client_payload = ctx.step(res['payload']) |
| 72 | + cmd = SON([('saslContinue', 1), |
| 73 | + ('conversationId', server_first['conversationId']), |
| 74 | + ('payload', client_payload)]) |
| 75 | + res = sock_info.command('$external', cmd) |
| 76 | + if res['done']: |
| 77 | + # SASL complete. |
| 78 | + break |
| 79 | + except PyMongoAuthAwsError as exc: |
| 80 | + # Convert to OperationFailure and include pymongo-auth-aws version. |
| 81 | + raise OperationFailure('%s (pymongo-auth-aws version %s)' % ( |
| 82 | + exc, pymongo_auth_aws.__version__)) |
0 commit comments