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