1# Copyright 2016 OpenStack Foundation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#    http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12# implied. See the License for the specific language governing
13# permissions and limitations under the License.
14
15"""
16Authentication plugin for keystoneauth to support v1 endpoints.
17
18Way back in the long-long ago, there was no Keystone. Swift used an auth
19mechanism now known as "v1", which used only HTTP headers. Auth requests
20and responses would look something like::
21
22   > GET /auth/v1.0 HTTP/1.1
23   > Host: <swift server>
24   > X-Auth-User: <tenant>:<user>
25   > X-Auth-Key: <password>
26   >
27   < HTTP/1.1 200 OK
28   < X-Storage-Url: http://<swift server>/v1/<tenant account>
29   < X-Auth-Token: <token>
30   < X-Storage-Token: <token>
31   <
32
33This plugin provides a way for Keystone sessions (and clients that
34use them, like python-openstackclient) to communicate with old auth
35endpoints that still use this mechanism, such as tempauth, swauth,
36or https://identity.api.rackspacecloud.com/v1.0
37"""
38
39import datetime
40import json
41import time
42
43from six.moves.urllib.parse import urljoin
44
45# Note that while we import keystoneauth1 here, we *don't* need to add it to
46# requirements.txt -- this entire module only makes sense (and should only be
47# loaded) if keystoneauth is already installed.
48from keystoneauth1 import discover
49from keystoneauth1 import plugin
50from keystoneauth1 import exceptions
51from keystoneauth1 import loading
52from keystoneauth1.identity import base
53
54
55# stupid stdlib...
56class _UTC(datetime.tzinfo):
57    def utcoffset(self, dt):
58        return datetime.timedelta(0)
59
60    def tzname(self, dt):
61        return "UTC"
62
63    def dst(self, dt):
64        return datetime.timedelta(0)
65
66
67UTC = _UTC()
68del _UTC
69
70
71class ServiceCatalogV1(object):
72    def __init__(self, auth_url, storage_url, account):
73        self.auth_url = auth_url
74        self._storage_url = storage_url
75        self._account = account
76
77    @property
78    def storage_url(self):
79        if self._account:
80            return urljoin(self._storage_url.rstrip('/'), self._account)
81        return self._storage_url
82
83    @property
84    def catalog(self):
85        # openstackclient wants this for the `catalog list` and
86        # `catalog show` commands
87        endpoints = [{
88            'region': 'default',
89            'publicURL': self._storage_url,
90        }]
91        if self.storage_url != self._storage_url:
92            endpoints.insert(0, {
93                'region': 'override',
94                'publicURL': self.storage_url,
95            })
96
97        return [
98            {
99                'name': 'swift',
100                'type': 'object-store',
101                'endpoints': endpoints,
102            },
103            {
104                'name': 'auth',
105                'type': 'identity',
106                'endpoints': [{
107                    'region': 'default',
108                    'publicURL': self.auth_url,
109                }],
110            }
111        ]
112
113    def url_for(self, **kwargs):
114        return self.endpoint_data_for(**kwargs).url
115
116    def endpoint_data_for(self, **kwargs):
117        kwargs.setdefault('interface', 'public')
118        kwargs.setdefault('service_type', None)
119
120        if kwargs['service_type'] == 'object-store':
121            return discover.EndpointData(
122                service_type='object-store',
123                service_name='swift',
124                interface=kwargs['interface'],
125                region_name='default',
126                catalog_url=self.storage_url,
127            )
128
129        # Although our "catalog" includes an identity entry, nothing that uses
130        # url_for() (including `openstack endpoint list`) will know what to do
131        # with it. Better to just raise the exception, cribbing error messages
132        # from keystoneauth1/access/service_catalog.py
133
134        if 'service_name' in kwargs and 'region_name' in kwargs:
135            msg = ('%(interface)s endpoint for %(service_type)s service '
136                   'named %(service_name)s in %(region_name)s region not '
137                   'found' % kwargs)
138        elif 'service_name' in kwargs:
139            msg = ('%(interface)s endpoint for %(service_type)s service '
140                   'named %(service_name)s not found' % kwargs)
141        elif 'region_name' in kwargs:
142            msg = ('%(interface)s endpoint for %(service_type)s service '
143                   'in %(region_name)s region not found' % kwargs)
144        else:
145            msg = ('%(interface)s endpoint for %(service_type)s service '
146                   'not found' % kwargs)
147
148        raise exceptions.EndpointNotFound(msg)
149
150
151class AccessInfoV1(object):
152    """An object for encapsulating a raw v1 auth token."""
153
154    def __init__(self, auth_url, storage_url, account, username, auth_token,
155                 token_life):
156        self.auth_url = auth_url
157        self.storage_url = storage_url
158        self.account = account
159        self.service_catalog = ServiceCatalogV1(auth_url, storage_url, account)
160        self.username = username
161        self.auth_token = auth_token
162        self._issued = time.time()
163        try:
164            self._expires = self._issued + float(token_life)
165        except (TypeError, ValueError):
166            self._expires = None
167        # following is used by openstackclient
168        self.project_id = None
169
170    @property
171    def expires(self):
172        if self._expires is None:
173            return None
174        return datetime.datetime.fromtimestamp(self._expires, UTC)
175
176    @property
177    def issued(self):
178        return datetime.datetime.fromtimestamp(self._issued, UTC)
179
180    @property
181    def user_id(self):
182        # openstackclient wants this for the `token issue` command
183        return self.username
184
185    def will_expire_soon(self, stale_duration):
186        """Determines if expiration is about to occur.
187
188        :returns: true if expiration is within the given duration
189        """
190        if self._expires is None:
191            return False  # assume no expiration
192        return time.time() + stale_duration > self._expires
193
194    def get_state(self):
195        """Serialize the current state."""
196        return json.dumps({
197            'auth_url': self.auth_url,
198            'storage_url': self.storage_url,
199            'account': self.account,
200            'username': self.username,
201            'auth_token': self.auth_token,
202            'issued': self._issued,
203            'expires': self._expires}, sort_keys=True)
204
205    @classmethod
206    def from_state(cls, data):
207        """Deserialize the given state.
208
209        :returns: a new AccessInfoV1 object with the given state
210        """
211        data = json.loads(data)
212        access = cls(
213            data['auth_url'],
214            data['storage_url'],
215            data['account'],
216            data['username'],
217            data['auth_token'],
218            token_life=None)
219        access._issued = data['issued']
220        access._expires = data['expires']
221        return access
222
223
224class PasswordPlugin(base.BaseIdentityPlugin):
225    """A plugin for authenticating with a username and password.
226
227    Subclassing from BaseIdentityPlugin gets us a few niceties, like handling
228    token invalidation and locking during authentication.
229
230    :param string auth_url: Identity v1 endpoint for authorization.
231    :param string username: Username for authentication.
232    :param string password: Password for authentication.
233    :param string project_name: Swift account to use after authentication.
234                                We use 'project_name' to be consistent with
235                                other auth plugins.
236    :param string reauthenticate: Whether to allow re-authentication.
237    """
238    access_class = AccessInfoV1
239
240    def __init__(self, auth_url, username, password, project_name=None,
241                 reauthenticate=True):
242        super(PasswordPlugin, self).__init__(
243            auth_url=auth_url,
244            reauthenticate=reauthenticate)
245        self.user = username
246        self.key = password
247        self.account = project_name
248
249    def get_auth_ref(self, session, **kwargs):
250        """Obtain a token from a v1 endpoint.
251
252        This function should not be called independently and is expected to be
253        invoked via the do_authenticate function.
254
255        This function will be invoked if the AcessInfo object cached by the
256        plugin is not valid. Thus plugins should always fetch a new AccessInfo
257        when invoked. If you are looking to just retrieve the current auth
258        data then you should use get_access.
259
260        :param session: A session object that can be used for communication.
261
262        :returns: Token access information.
263        """
264        headers = {'X-Auth-User': self.user,
265                   'X-Auth-Key': self.key}
266
267        resp = session.get(self.auth_url, headers=headers,
268                           authenticated=False, log=False)
269
270        if resp.status_code // 100 != 2:
271            raise exceptions.InvalidResponse(response=resp)
272
273        if 'X-Storage-Url' not in resp.headers:
274            raise exceptions.InvalidResponse(response=resp)
275
276        if 'X-Auth-Token' not in resp.headers and \
277                'X-Storage-Token' not in resp.headers:
278            raise exceptions.InvalidResponse(response=resp)
279        token = resp.headers.get('X-Storage-Token',
280                                 resp.headers.get('X-Auth-Token'))
281        return AccessInfoV1(
282            auth_url=self.auth_url,
283            storage_url=resp.headers['X-Storage-Url'],
284            account=self.account,
285            username=self.user,
286            auth_token=token,
287            token_life=resp.headers.get('X-Auth-Token-Expires'))
288
289    def get_cache_id_elements(self):
290        """Get the elements for this auth plugin that make it unique."""
291        return {'auth_url': self.auth_url,
292                'user': self.user,
293                'key': self.key,
294                'account': self.account}
295
296    def get_endpoint(self, session, interface='public', **kwargs):
297        """Return an endpoint for the client."""
298        if interface is plugin.AUTH_INTERFACE:
299            return self.auth_url
300        else:
301            return self.get_access(session).service_catalog.url_for(
302                interface=interface, **kwargs)
303
304    def get_auth_state(self):
305        """Retrieve the current authentication state for the plugin.
306
307        :returns: raw python data (which can be JSON serialized) that can be
308                  moved into another plugin (of the same type) to have the
309                  same authenticated state.
310        """
311        if self.auth_ref:
312            return self.auth_ref.get_state()
313
314    def set_auth_state(self, data):
315        """Install existing authentication state for a plugin.
316
317        Take the output of get_auth_state and install that authentication state
318        into the current authentication plugin.
319        """
320        if data:
321            self.auth_ref = self.access_class.from_state(data)
322        else:
323            self.auth_ref = None
324
325    def get_sp_auth_url(self, *args, **kwargs):
326        raise NotImplementedError()
327
328    def get_sp_url(self, *args, **kwargs):
329        raise NotImplementedError()
330
331    def get_discovery(self, *args, **kwargs):
332        raise NotImplementedError()
333
334
335class PasswordLoader(loading.BaseLoader):
336    """Option handling for the ``v1password`` plugin."""
337    plugin_class = PasswordPlugin
338
339    def get_options(self):
340        """Return the list of parameters associated with the auth plugin.
341
342        This list may be used to generate CLI or config arguments.
343        """
344        return [
345            loading.Opt('auth-url', required=True,
346                        help='Authentication URL'),
347            # overload project-name as a way to specify an alternate account,
348            # since:
349            #   - in a world of just users & passwords, this seems the closest
350            #     analog to a project, and
351            #   - openstackclient will (or used to?) still require that you
352            #     provide one anyway
353            loading.Opt('project-name', required=False,
354                        help='Swift account to use'),
355            loading.Opt('username', required=True,
356                        deprecated=[loading.Opt('user-name')],
357                        help='Username to login with'),
358            loading.Opt('password', required=True, secret=True,
359                        help='Password to use'),
360        ]
361