|
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