1# Licensed under the Apache License, Version 2.0 (the "License");
2# you may not use this file except in compliance with the License.
3# You may obtain a copy of the License at
4#
5#    http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS,
9# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
10# implied.
11# See the License for the specific language governing permissions and
12# limitations under the License.
13
14import datetime
15import logging
16import re
17from urllib import parse
18
19from django.conf import settings
20from django.contrib import auth
21from django.contrib.auth import models
22from django.utils import timezone
23from keystoneauth1.identity import v3 as v3_auth
24from keystoneauth1 import session
25from keystoneauth1 import token_endpoint
26from keystoneclient.v3 import client as client_v3
27
28from openstack_auth import defaults
29
30LOG = logging.getLogger(__name__)
31
32"""
33We need the request object to get the user, so we'll slightly modify the
34existing django.contrib.auth.get_user method. To do so we update the
35auth middleware to point to our overridden method.
36
37Calling "patch_middleware_get_user" is done in our custom middleware at
38"openstack_auth.middleware" to monkeypatch the code in before it is needed.
39"""
40
41
42def middleware_get_user(request):
43    if not hasattr(request, '_cached_user'):
44        request._cached_user = get_user(request)
45    return request._cached_user
46
47
48def get_user(request):
49    try:
50        user_id = request.session[auth.SESSION_KEY]
51        backend_path = request.session[auth.BACKEND_SESSION_KEY]
52        backend = auth.load_backend(backend_path)
53        backend.request = request
54        user = backend.get_user(user_id) or models.AnonymousUser()
55    except KeyError:
56        user = models.AnonymousUser()
57    return user
58
59
60def patch_middleware_get_user():
61    # NOTE(adriant): We can't import middleware until our customer user model
62    # is actually registered, otherwise a call to get_user_model within the
63    # middleware module will fail.
64    from django.contrib.auth import middleware
65    middleware.get_user = middleware_get_user
66    auth.get_user = get_user
67
68
69""" End Monkey-Patching. """
70
71
72def is_token_valid(token, margin=None):
73    """Timezone-aware checking of the auth token's expiration timestamp.
74
75    Returns ``True`` if the token has not yet expired, otherwise ``False``.
76
77    :param token: The openstack_auth.user.Token instance to check
78
79    :param margin:
80       A time margin in seconds to subtract from the real token's validity.
81       An example usage is that the token can be valid once the middleware
82       passed, and invalid (timed-out) during a view rendering and this
83       generates authorization errors during the view rendering.
84       A default margin can be set by the TOKEN_TIMEOUT_MARGIN in the
85       django settings.
86    """
87    expiration = token.expires
88    # In case we get an unparseable expiration timestamp, return False
89    # so you can't have a "forever" token just by breaking the expires param.
90    if expiration is None:
91        return False
92    if margin is None:
93        margin = settings.TOKEN_TIMEOUT_MARGIN
94    expiration = expiration - datetime.timedelta(seconds=margin)
95    if settings.USE_TZ and timezone.is_naive(expiration):
96        # Presumes that the Keystone is using UTC.
97        expiration = timezone.make_aware(expiration, timezone.utc)
98    return expiration > timezone.now()
99
100
101# NOTE(amotoki):
102# This is a copy from openstack_dashboard.utils.settings.get_dict_config().
103# This copy is needed to look up defaults for openstack_auth.defaults
104# instead of openstack_dashboard.defaults.
105# TODO(amotoki): This copy might be cleanup if we can use oslo.config
106# for openstack_auth configurations.
107def _get_dict_config(name, key):
108    config = getattr(settings, name)
109    if key in config:
110        return config[key]
111    return getattr(defaults, name)[key]
112
113
114# Helper for figuring out keystone version
115# Implementation will change when API version discovery is available
116def get_keystone_version():
117    return _get_dict_config('OPENSTACK_API_VERSIONS', 'identity')
118
119
120def get_session(**kwargs):
121    insecure = settings.OPENSTACK_SSL_NO_VERIFY
122    verify = settings.OPENSTACK_SSL_CACERT
123
124    if insecure:
125        verify = False
126
127    return session.Session(verify=verify, **kwargs)
128
129
130def get_keystone_client():
131    return client_v3
132
133
134def allow_expired_passowrd_change():
135    """Checks if users should be able to change their expired passwords."""
136    return getattr(settings, 'ALLOW_USERS_CHANGE_EXPIRED_PASSWORD', True)
137
138
139def build_absolute_uri(request, relative_url):
140    """Ensure absolute_uri are relative to WEBROOT."""
141    webroot = settings.WEBROOT
142    if webroot.endswith("/") and relative_url.startswith("/"):
143        webroot = webroot[:-1]
144
145    return request.build_absolute_uri(webroot + relative_url)
146
147
148def get_websso_url(request, auth_url, websso_auth):
149    """Return the keystone endpoint for initiating WebSSO.
150
151    Generate the keystone WebSSO endpoint that will redirect the user
152    to the login page of the federated identity provider.
153
154    Based on the authentication type selected by the user in the login
155    form, it will construct the keystone WebSSO endpoint.
156
157    :param request: Django http request object.
158    :type request: django.http.HttpRequest
159    :param auth_url: Keystone endpoint configured in the horizon setting.
160                     If WEBSSO_KEYSTONE_URL is defined, its value will be
161                     used. Otherwise, the value is derived from:
162                     - OPENSTACK_KEYSTONE_URL
163                     - AVAILABLE_REGIONS
164    :type auth_url: string
165    :param websso_auth: Authentication type selected by the user from the
166                        login form. The value is derived from the horizon
167                        setting WEBSSO_CHOICES.
168    :type websso_auth: string
169
170    Example of horizon WebSSO setting::
171
172        WEBSSO_CHOICES = (
173            ("credentials", "Keystone Credentials"),
174            ("oidc", "OpenID Connect"),
175            ("saml2", "Security Assertion Markup Language"),
176            ("acme_oidc", "ACME - OpenID Connect"),
177            ("acme_saml2", "ACME - SAML2")
178        )
179
180        WEBSSO_IDP_MAPPING = {
181            "acme_oidc": ("acme", "oidc"),
182            "acme_saml2": ("acme", "saml2")
183            }
184        }
185
186    The value of websso_auth will be looked up in the WEBSSO_IDP_MAPPING
187    dictionary, if a match is found it will return a IdP specific WebSSO
188    endpoint using the values found in the mapping.
189
190    The value in WEBSSO_IDP_MAPPING is expected to be a tuple formatted as
191    (<idp_id>, <protocol_id>). Using the values found, a IdP/protocol
192    specific URL will be constructed::
193
194        /auth/OS-FEDERATION/identity_providers/<idp_id>
195        /protocols/<protocol_id>/websso
196
197    If no value is found from the WEBSSO_IDP_MAPPING dictionary, it will
198    treat the value as the global WebSSO protocol <protocol_id> and
199    construct the WebSSO URL by::
200
201        /auth/OS-FEDERATION/websso/<protocol_id>
202
203    :returns: Keystone WebSSO endpoint.
204    :rtype: string
205
206    """
207    origin = build_absolute_uri(request, '/auth/websso/')
208    idp_mapping = settings.WEBSSO_IDP_MAPPING
209    idp_id, protocol_id = idp_mapping.get(websso_auth,
210                                          (None, websso_auth))
211
212    if idp_id:
213        # Use the IDP specific WebSSO endpoint
214        url = ('%s/auth/OS-FEDERATION/identity_providers/%s'
215               '/protocols/%s/websso?origin=%s' %
216               (auth_url, idp_id, protocol_id, origin))
217    else:
218        # If no IDP mapping found for the identifier,
219        # perform WebSSO by protocol.
220        url = ('%s/auth/OS-FEDERATION/websso/%s?origin=%s' %
221               (auth_url, protocol_id, origin))
222
223    return url
224
225
226def has_in_url_path(url, subs):
227    """Test if any of `subs` strings is present in the `url` path."""
228    scheme, netloc, path, query, fragment = parse.urlsplit(url)
229    return any([sub in path for sub in subs])
230
231
232def url_path_replace(url, old, new, count=None):
233    """Return a copy of url with replaced path.
234
235    Return a copy of url with all occurrences of old replaced by new in the url
236    path.  If the optional argument count is given, only the first count
237    occurrences are replaced.
238    """
239    args = []
240    scheme, netloc, path, query, fragment = parse.urlsplit(url)
241    if count is not None:
242        args.append(count)
243    return parse.urlunsplit((
244        scheme, netloc, path.replace(old, new, *args), query, fragment))
245
246
247def url_path_append(url, suffix):
248    scheme, netloc, path, query, fragment = parse.urlsplit(url)
249    path = (path + suffix).replace('//', '/')
250    return parse.urlunsplit((scheme, netloc, path, query, fragment))
251
252
253def _augment_url_with_version(auth_url):
254    """Optionally augment auth_url path with version suffix.
255
256    Check if path component already contains version suffix and if it does
257    not, append version suffix to the end of path, not erasing the previous
258    path contents, since keystone web endpoint (like /identity) could be
259    there. Keystone version needs to be added to endpoint because as of Kilo,
260    the identity URLs returned by Keystone might no longer contain API
261    versions, leaving the version choice up to the user.
262    """
263    if has_in_url_path(auth_url, ["/v3"]):
264        return auth_url
265
266    return url_path_append(auth_url, "/v3")
267
268
269def fix_auth_url_version_prefix(auth_url):
270    """Fix up the auth url if an invalid or no version prefix was given.
271
272    Fix the URL to say v3 in this case and add version if it is
273    missing entirely. This should be smarter and use discovery.
274    Until version discovery is implemented we need this method to get
275    everything working.
276    """
277    auth_url = _augment_url_with_version(auth_url)
278
279    url_fixed = False
280    if has_in_url_path(auth_url, ["/v2.0"]):
281        url_fixed = True
282        auth_url = url_path_replace(auth_url, "/v2.0", "/v3", 1)
283
284    return auth_url, url_fixed
285
286
287def clean_up_auth_url(auth_url):
288    """Clean up the auth url to extract the exact Keystone URL"""
289
290    # NOTE(mnaser): This drops the query and fragment because we're only
291    #               trying to extract the Keystone URL.
292    scheme, netloc, path, query, fragment = parse.urlsplit(auth_url)
293    return parse.urlunsplit((
294        scheme, netloc, re.sub(r'/auth.*', '', path), '', ''))
295
296
297def get_token_auth_plugin(auth_url, token, project_id=None, domain_name=None):
298    if domain_name:
299        return v3_auth.Token(auth_url=auth_url,
300                             token=token,
301                             domain_name=domain_name,
302                             reauthenticate=False)
303    return v3_auth.Token(auth_url=auth_url,
304                         token=token,
305                         project_id=project_id,
306                         reauthenticate=False)
307
308
309def get_project_list(*args, **kwargs):
310    is_federated = kwargs.get('is_federated', False)
311    sess = kwargs.get('session') or get_session()
312    auth_url, _ = fix_auth_url_version_prefix(kwargs['auth_url'])
313    auth = token_endpoint.Token(auth_url, kwargs['token'])
314    client = get_keystone_client().Client(session=sess, auth=auth)
315
316    if is_federated:
317        projects = client.federation.projects.list()
318    else:
319        projects = client.projects.list(user=kwargs.get('user_id'))
320
321    projects.sort(key=lambda project: project.name.lower())
322    return projects
323
324
325def default_services_region(service_catalog, request=None,
326                            ks_endpoint=None):
327    """Return the default service region.
328
329    Order of precedence:
330    1. 'services_region' cookie value
331    2. Matching endpoint in DEFAULT_SERVICE_REGIONS
332    3. '*' key in DEFAULT_SERVICE_REGIONS
333    4. First valid region from catalog
334
335    In each case the value must also be present in available_regions or
336    we move to the next level of precedence.
337    """
338    if service_catalog:
339        available_regions = [get_endpoint_region(endpoint) for service
340                             in service_catalog for endpoint
341                             in service.get('endpoints', [])
342                             if (service.get('type') is not None and
343                                 service.get('type') != 'identity')]
344        if not available_regions:
345            # this is very likely an incomplete keystone setup
346            LOG.warning('No regions could be found excluding identity.')
347            available_regions = [get_endpoint_region(endpoint) for service
348                                 in service_catalog for endpoint
349                                 in service.get('endpoints', [])]
350
351            if not available_regions:
352                # if there are no region setup for any service endpoint,
353                # this is a critical problem and it's not clear how this occurs
354                LOG.error('No regions can be found in the service catalog.')
355                return None
356
357        region_options = []
358        if request:
359            region_options.append(request.COOKIES.get('services_region'))
360        if ks_endpoint:
361            default_service_regions = settings.DEFAULT_SERVICE_REGIONS
362            region_options.append(default_service_regions.get(ks_endpoint))
363        region_options.append(settings.DEFAULT_SERVICE_REGIONS.get('*'))
364
365        for region in region_options:
366            if region in available_regions:
367                return region
368        return available_regions[0]
369    return None
370
371
372def set_response_cookie(response, cookie_name, cookie_value):
373    """Common function for setting the cookie in the response.
374
375    Provides a common policy of setting cookies for last used project
376    and region, can be reused in other locations.
377
378    This method will set the cookie to expire in 365 days.
379    """
380    now = timezone.now()
381    expire_date = now + datetime.timedelta(days=365)
382    response.set_cookie(cookie_name, cookie_value, expires=expire_date)
383
384
385def get_endpoint_region(endpoint):
386    """Common function for getting the region from endpoint.
387
388    In Keystone V3, region has been deprecated in favor of
389    region_id.
390
391    This method provides a way to get region that works for both
392    Keystone V2 and V3.
393    """
394    return endpoint.get('region_id') or endpoint.get('region')
395
396
397def using_cookie_backed_sessions():
398    engine = settings.SESSION_ENGINE
399    return "signed_cookies" in engine
400
401
402def get_admin_roles():
403    """Common function for getting the admin roles from settings
404
405    :return:
406      Set object including all admin roles.
407      If there is no role, this will return empty::
408
409        {
410            "foo", "bar", "admin"
411        }
412
413    """
414    admin_roles = {role.lower() for role
415                   in settings.OPENSTACK_KEYSTONE_ADMIN_ROLES}
416    return admin_roles
417
418
419def get_role_permission(role):
420    """Common function for getting the permission froms arg
421
422    This format is 'openstack.roles.xxx' and 'xxx' is a real role name.
423
424    :returns:
425        String like "openstack.roles.admin"
426        If role is None, this will return None.
427
428    """
429    return "openstack.roles.%s" % role.lower()
430
431
432def get_admin_permissions():
433    """Common function for getting the admin permissions from settings
434
435    This format is 'openstack.roles.xxx' and 'xxx' is a real role name.
436
437    :returns:
438       Set object including all admin permission.
439       If there is no permission, this will return empty::
440
441        {
442            "openstack.roles.foo",
443            "openstack.roles.bar",
444            "openstack.roles.admin"
445        }
446
447    """
448    return {get_role_permission(role) for role in get_admin_roles()}
449
450
451def get_client_ip(request):
452    """Return client ip address using SECURE_PROXY_ADDR_HEADER variable.
453
454    If not present or not defined on settings then REMOTE_ADDR is used.
455
456    :param request: Django http request object.
457    :type request: django.http.HttpRequest
458
459    :returns: Possible client ip address
460    :rtype: string
461    """
462    _SECURE_PROXY_ADDR_HEADER = settings.SECURE_PROXY_ADDR_HEADER
463    if _SECURE_PROXY_ADDR_HEADER:
464        return request.META.get(
465            _SECURE_PROXY_ADDR_HEADER,
466            request.META.get('REMOTE_ADDR')
467        )
468    return request.META.get('REMOTE_ADDR')
469
470
471def store_initial_k2k_session(auth_url, request, scoped_auth_ref,
472                              unscoped_auth_ref):
473    """Stores session variables if there are k2k service providers
474
475    This stores variables related to Keystone2Keystone federation. This
476    function gets skipped if there are no Keystone service providers.
477    An unscoped token to the identity provider keystone gets stored
478    so that it can be used to do federated login into the service
479    providers when switching keystone providers.
480    The settings file can be configured to set the display name
481    of the local (identity provider) keystone by setting
482    KEYSTONE_PROVIDER_IDP_NAME. The KEYSTONE_PROVIDER_IDP_ID settings
483    variable is used for comparison against the service providers.
484    It should not conflict with any of the service provider ids.
485
486    :param auth_url: base token auth url
487    :param request: Django http request object
488    :param scoped_auth_ref: Scoped Keystone access info object
489    :param unscoped_auth_ref: Unscoped Keystone access info object
490    """
491    keystone_provider_id = request.session.get('keystone_provider_id', None)
492    if keystone_provider_id:
493        return None
494
495    providers = getattr(scoped_auth_ref, 'service_providers', None)
496    if providers:
497        providers = getattr(providers, '_service_providers', None)
498
499    if providers:
500        keystone_idp_name = settings.KEYSTONE_PROVIDER_IDP_NAME
501        keystone_idp_id = settings.KEYSTONE_PROVIDER_IDP_ID
502        keystone_identity_provider = {'name': keystone_idp_name,
503                                      'id': keystone_idp_id}
504        # (edtubill) We will use the IDs as the display names
505        # We may want to be able to set display names in the future.
506        keystone_providers = [
507            {'name': provider_id, 'id': provider_id}
508            for provider_id in providers]
509
510        keystone_providers.append(keystone_identity_provider)
511
512        # We treat the Keystone idp ID as None
513        request.session['keystone_provider_id'] = keystone_idp_id
514        request.session['keystone_providers'] = keystone_providers
515        request.session['k2k_base_unscoped_token'] =\
516            unscoped_auth_ref.auth_token
517        request.session['k2k_auth_url'] = auth_url
518