d3e2893c4613d9cd565e9fe18ca484896e0c8b9a
[escalator.git] / api / escalator / common / auth.py
1 # Copyright 2011 OpenStack Foundation
2 # All Rights Reserved.
3 #
4 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
5 #    not use this file except in compliance with the License. You may obtain
6 #    a copy of the License at
7 #
8 #         http://www.apache.org/licenses/LICENSE-2.0
9 #
10 #    Unless required by applicable law or agreed to in writing, software
11 #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 #    License for the specific language governing permissions and limitations
14 #    under the License.
15
16 """
17 This auth module is intended to allow OpenStack client-tools to select from a
18 variety of authentication strategies, including NoAuth (the default), and
19 Keystone (an identity management system).
20
21     > auth_plugin = AuthPlugin(creds)
22
23     > auth_plugin.authenticate()
24
25     > auth_plugin.auth_token
26     abcdefg
27
28     > auth_plugin.management_url
29     http://service_endpoint/
30 """
31 import httplib2
32 from oslo_serialization import jsonutils
33 from oslo_log import log as logging
34 # NOTE(jokke): simplified transition to py3, behaves like py2 xrange
35 from six.moves import range
36 import six.moves.urllib.parse as urlparse
37
38 from escalator.common import exception
39 from escalator import i18n
40
41
42 LOG = logging.getLogger(__name__)
43 _ = i18n._
44
45
46 class BaseStrategy(object):
47
48     def __init__(self):
49         self.auth_token = None
50         # TODO(sirp): Should expose selecting public/internal/admin URL.
51         self.management_url = None
52
53     def authenticate(self):
54         raise NotImplementedError
55
56     @property
57     def is_authenticated(self):
58         raise NotImplementedError
59
60     @property
61     def strategy(self):
62         raise NotImplementedError
63
64
65 class NoAuthStrategy(BaseStrategy):
66
67     def authenticate(self):
68         pass
69
70     @property
71     def is_authenticated(self):
72         return True
73
74     @property
75     def strategy(self):
76         return 'noauth'
77
78
79 class KeystoneStrategy(BaseStrategy):
80     MAX_REDIRECTS = 10
81
82     def __init__(self, creds, insecure=False, configure_via_auth=True):
83         self.creds = creds
84         self.insecure = insecure
85         self.configure_via_auth = configure_via_auth
86         super(KeystoneStrategy, self).__init__()
87
88     def check_auth_params(self):
89         # Ensure that supplied credential parameters are as required
90         for required in ('username', 'password', 'auth_url',
91                          'strategy'):
92             if self.creds.get(required) is None:
93                 raise exception.MissingCredentialError(required=required)
94         if self.creds['strategy'] != 'keystone':
95             raise exception.BadAuthStrategy(expected='keystone',
96                                             received=self.creds['strategy'])
97         # For v2.0 also check tenant is present
98         if self.creds['auth_url'].rstrip('/').endswith('v2.0'):
99             if self.creds.get("tenant") is None:
100                 raise exception.MissingCredentialError(required='tenant')
101
102     def authenticate(self):
103         """Authenticate with the Keystone service.
104
105         There are a few scenarios to consider here:
106
107         1. Which version of Keystone are we using? v1 which uses headers to
108            pass the credentials, or v2 which uses a JSON encoded request body?
109
110         2. Keystone may respond back with a redirection using a 305 status
111            code.
112
113         3. We may attempt a v1 auth when v2 is what's called for. In this
114            case, we rewrite the url to contain /v2.0/ and retry using the v2
115            protocol.
116         """
117         def _authenticate(auth_url):
118             # If OS_AUTH_URL is missing a trailing slash add one
119             if not auth_url.endswith('/'):
120                 auth_url += '/'
121             token_url = urlparse.urljoin(auth_url, "tokens")
122             # 1. Check Keystone version
123             is_v2 = auth_url.rstrip('/').endswith('v2.0')
124             if is_v2:
125                 self._v2_auth(token_url)
126             else:
127                 self._v1_auth(token_url)
128
129         self.check_auth_params()
130         auth_url = self.creds['auth_url']
131         for _ in range(self.MAX_REDIRECTS):
132             try:
133                 _authenticate(auth_url)
134             except exception.AuthorizationRedirect as e:
135                 # 2. Keystone may redirect us
136                 auth_url = e.url
137             except exception.AuthorizationFailure:
138                 # 3. In some configurations nova makes redirection to
139                 # v2.0 keystone endpoint. Also, new location does not
140                 # contain real endpoint, only hostname and port.
141                 if 'v2.0' not in auth_url:
142                     auth_url = urlparse.urljoin(auth_url, 'v2.0/')
143             else:
144                 # If we successfully auth'd, then memorize the correct auth_url
145                 # for future use.
146                 self.creds['auth_url'] = auth_url
147                 break
148         else:
149             # Guard against a redirection loop
150             raise exception.MaxRedirectsExceeded(redirects=self.MAX_REDIRECTS)
151
152     def _v1_auth(self, token_url):
153         creds = self.creds
154
155         headers = {}
156         headers['X-Auth-User'] = creds['username']
157         headers['X-Auth-Key'] = creds['password']
158
159         tenant = creds.get('tenant')
160         if tenant:
161             headers['X-Auth-Tenant'] = tenant
162
163         resp, resp_body = self._do_request(token_url, 'GET', headers=headers)
164
165         def _management_url(self, resp):
166             for url_header in ('x-image-management-url',
167                                'x-server-management-url',
168                                'x-escalator'):
169                 try:
170                     return resp[url_header]
171                 except KeyError as e:
172                     not_found = e
173             raise not_found
174
175         if resp.status in (200, 204):
176             try:
177                 if self.configure_via_auth:
178                     self.management_url = _management_url(self, resp)
179                 self.auth_token = resp['x-auth-token']
180             except KeyError:
181                 raise exception.AuthorizationFailure()
182         elif resp.status == 305:
183             raise exception.AuthorizationRedirect(uri=resp['location'])
184         elif resp.status == 400:
185             raise exception.AuthBadRequest(url=token_url)
186         elif resp.status == 401:
187             raise exception.NotAuthenticated()
188         elif resp.status == 404:
189             raise exception.AuthUrlNotFound(url=token_url)
190         else:
191             raise Exception(_('Unexpected response: %s') % resp.status)
192
193     def _v2_auth(self, token_url):
194
195         creds = self.creds
196
197         creds = {
198             "auth": {
199                 "tenantName": creds['tenant'],
200                 "passwordCredentials": {
201                     "username": creds['username'],
202                     "password": creds['password']
203                 }
204             }
205         }
206
207         headers = {}
208         headers['Content-Type'] = 'application/json'
209         req_body = jsonutils.dumps(creds)
210
211         resp, resp_body = self._do_request(
212             token_url, 'POST', headers=headers, body=req_body)
213
214         if resp.status == 200:
215             resp_auth = jsonutils.loads(resp_body)['access']
216             creds_region = self.creds.get('region')
217             if self.configure_via_auth:
218                 endpoint = get_endpoint(resp_auth['serviceCatalog'],
219                                         endpoint_region=creds_region)
220                 self.management_url = endpoint
221             self.auth_token = resp_auth['token']['id']
222         elif resp.status == 305:
223             raise exception.RedirectException(resp['location'])
224         elif resp.status == 400:
225             raise exception.AuthBadRequest(url=token_url)
226         elif resp.status == 401:
227             raise exception.NotAuthenticated()
228         elif resp.status == 404:
229             raise exception.AuthUrlNotFound(url=token_url)
230         else:
231             raise Exception(_('Unexpected response: %s') % resp.status)
232
233     @property
234     def is_authenticated(self):
235         return self.auth_token is not None
236
237     @property
238     def strategy(self):
239         return 'keystone'
240
241     def _do_request(self, url, method, headers=None, body=None):
242         headers = headers or {}
243         conn = httplib2.Http()
244         conn.force_exception_to_status_code = True
245         conn.disable_ssl_certificate_validation = self.insecure
246         headers['User-Agent'] = 'escalator-client'
247         resp, resp_body = conn.request(url, method, headers=headers, body=body)
248         return resp, resp_body
249
250
251 def get_plugin_from_strategy(strategy, creds=None, insecure=False,
252                              configure_via_auth=True):
253     if strategy == 'noauth':
254         return NoAuthStrategy()
255     elif strategy == 'keystone':
256         return KeystoneStrategy(creds, insecure,
257                                 configure_via_auth=configure_via_auth)
258     else:
259         raise Exception(_("Unknown auth strategy '%s'") % strategy)
260
261
262 def get_endpoint(service_catalog, service_type='image', endpoint_region=None,
263                  endpoint_type='publicURL'):
264     """
265     Select an endpoint from the service catalog
266
267     We search the full service catalog for services
268     matching both type and region. If the client
269     supplied no region then any 'image' endpoint
270     is considered a match. There must be one -- and
271     only one -- successful match in the catalog,
272     otherwise we will raise an exception.
273     """
274     endpoint = None
275     for service in service_catalog:
276         s_type = None
277         try:
278             s_type = service['type']
279         except KeyError:
280             msg = _('Encountered service with no "type": %s') % s_type
281             LOG.warn(msg)
282             continue
283
284         if s_type == service_type:
285             for ep in service['endpoints']:
286                 if endpoint_region is None or endpoint_region == ep['region']:
287                     if endpoint is not None:
288                         # This is a second match, abort
289                         raise exception.RegionAmbiguity(region=endpoint_region)
290                     endpoint = ep
291     if endpoint and endpoint.get(endpoint_type):
292         return endpoint[endpoint_type]
293     else:
294         raise exception.NoServiceEndpoint()