348 lines
11 KiB
Python
348 lines
11 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2010-2019 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import base64
|
|
import pickle
|
|
import re
|
|
import time
|
|
from contextlib import contextmanager
|
|
from functools import wraps
|
|
from json import dumps as json_dumps
|
|
|
|
from django.core.cache import cache as django_cache
|
|
from django.core.exceptions import ValidationError
|
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
|
from django.views.debug import technical_404_response
|
|
|
|
from . import app_settings, middleware
|
|
|
|
# XXX: import to_list for retrocompaibility
|
|
from .utils import to_iter, to_list # noqa: F401
|
|
|
|
|
|
class CacheUnusable(RuntimeError):
|
|
pass
|
|
|
|
|
|
def unless(test, message):
|
|
'''Decorator returning a 404 status code if some condition is not met'''
|
|
|
|
def decorator(func):
|
|
@wraps(func)
|
|
def f(request, *args, **kwargs):
|
|
if not test():
|
|
return technical_404_response(request, Http404(message))
|
|
return func(request, *args, **kwargs)
|
|
|
|
return f
|
|
|
|
return decorator
|
|
|
|
|
|
def setting_enabled(name, settings=app_settings):
|
|
'''Generate a decorator for enabling a view based on a setting'''
|
|
full_name = getattr(settings, 'prefix', '') + name
|
|
|
|
def test():
|
|
return getattr(settings, name, False)
|
|
|
|
return unless(test, 'please enable %s' % full_name)
|
|
|
|
|
|
def lasso_required():
|
|
def test():
|
|
try:
|
|
import lasso # noqa: F401
|
|
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
return unless(test, 'please install lasso')
|
|
|
|
|
|
def required(wrapping_functions, patterns_rslt):
|
|
"""
|
|
Used to require 1..n decorators in any view returned by a url tree
|
|
|
|
Usage:
|
|
urlpatterns = required(func,patterns(...))
|
|
urlpatterns = required((func,func,func),patterns(...))
|
|
|
|
Note:
|
|
Use functools.partial to pass keyword params to the required
|
|
decorators. If you need to pass args you will have to write a
|
|
wrapper function.
|
|
|
|
Example:
|
|
from functools import partial
|
|
|
|
urlpatterns = required(
|
|
partial(login_required,login_url='/accounts/login/'),
|
|
patterns(...)
|
|
)
|
|
"""
|
|
if not hasattr(wrapping_functions, '__iter__'):
|
|
wrapping_functions = (wrapping_functions,)
|
|
|
|
return [_wrap_instance__resolve(wrapping_functions, instance) for instance in patterns_rslt]
|
|
|
|
|
|
def _wrap_instance__resolve(wrapping_functions, instance):
|
|
if not hasattr(instance, 'resolve'):
|
|
return instance
|
|
resolve = getattr(instance, 'resolve')
|
|
|
|
def _wrap_func_in_returned_resolver_match(*args, **kwargs):
|
|
rslt = resolve(*args, **kwargs)
|
|
|
|
if not hasattr(rslt, 'func'):
|
|
return rslt
|
|
f = getattr(rslt, 'func')
|
|
|
|
for _f in reversed(wrapping_functions):
|
|
# @decorate the function from inner to outter
|
|
f = _f(f)
|
|
|
|
setattr(rslt, 'func', f)
|
|
|
|
return rslt
|
|
|
|
setattr(instance, 'resolve', _wrap_func_in_returned_resolver_match)
|
|
return instance
|
|
|
|
|
|
class CacheDecoratorBase:
|
|
"""Base class to build cache decorators.
|
|
|
|
It helps for building keys from function arguments.
|
|
"""
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
if len(args) > 1:
|
|
raise TypeError(
|
|
'%s got unexpected arguments, only one argument must be given, the function to decorate'
|
|
% cls.__name__
|
|
)
|
|
if args:
|
|
# Case of a decorator used directly
|
|
return cls(**kwargs)(args[0])
|
|
return super().__new__(cls)
|
|
|
|
def __init__(self, timeout=None, hostname_vary=True, args=None, kwargs=None):
|
|
self.timeout = timeout
|
|
self.hostname_vary = hostname_vary
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
|
|
def set(self, key, value):
|
|
raise NotImplementedError
|
|
|
|
def get(self, key):
|
|
raise NotImplementedError
|
|
|
|
def clear(self):
|
|
raise NotImplementedError
|
|
|
|
def __call__(self, func):
|
|
@wraps(func)
|
|
def f(*args, **kwargs):
|
|
try:
|
|
if not app_settings.A2_CACHE_ENABLED:
|
|
raise CacheUnusable
|
|
now = time.time()
|
|
key = self.key(*args, **kwargs)
|
|
value, tstamp = self.get(key)
|
|
if tstamp is not None:
|
|
if self.timeout is None or tstamp + self.timeout > now:
|
|
return value
|
|
if hasattr(self, 'delete'):
|
|
self.delete(key, (key, tstamp))
|
|
value = func(*args, **kwargs)
|
|
self.set(key, (value, now))
|
|
return value
|
|
except CacheUnusable: # fallback when cache cannot be used
|
|
return func(*args, **kwargs)
|
|
|
|
f.cache = self
|
|
return f
|
|
|
|
def key(self, *args, **kwargs):
|
|
'''Transform arguments to string and build a key from it'''
|
|
parts = [str(id(self))] # add cache instance to the key
|
|
if self.hostname_vary:
|
|
request = middleware.StoreRequestMiddleware.get_request()
|
|
if request:
|
|
parts.append(request.get_host())
|
|
else:
|
|
# if we cannot determine the hostname it's better to ignore the
|
|
# cache
|
|
raise CacheUnusable
|
|
for i, arg in enumerate(args):
|
|
if self.args and i not in self.args:
|
|
continue
|
|
parts.append(str(arg))
|
|
|
|
for kw, arg in sorted(kwargs.items(), key=lambda x: x[0]):
|
|
if kw not in self.kwargs:
|
|
continue
|
|
parts.append('%s-%s' % (str(kw), str(arg)))
|
|
return '|'.join(parts)
|
|
|
|
|
|
class SimpleDictionnaryCacheMixin:
|
|
"""Default implementations of set, get and delete for a cache implemented
|
|
using a dictionary. The dictionnary must be returned by a property named
|
|
'cache'.
|
|
"""
|
|
|
|
def set(self, key, value):
|
|
self.cache[key] = value
|
|
|
|
def get(self, key):
|
|
return self.cache.get(key, (None, None))
|
|
|
|
def delete(self, key, value):
|
|
if key in self.cache and self.cache[key] == value:
|
|
del self.cache[key]
|
|
|
|
def clear(self):
|
|
self.cache.clear()
|
|
|
|
|
|
class GlobalCache(SimpleDictionnaryCacheMixin, CacheDecoratorBase):
|
|
def __init__(self, *args, **kwargs):
|
|
self.cache = {}
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
class RequestCache(SimpleDictionnaryCacheMixin, CacheDecoratorBase):
|
|
@property
|
|
def cache(self):
|
|
request = middleware.StoreRequestMiddleware.get_request()
|
|
if not request:
|
|
return {}
|
|
# create a cache dictionary on the request
|
|
return request.__dict__.setdefault(self.__class__.__name__, {})
|
|
|
|
|
|
class DjangoCache(SimpleDictionnaryCacheMixin, CacheDecoratorBase):
|
|
@property
|
|
def cache(self):
|
|
return django_cache
|
|
|
|
def set(self, key, value):
|
|
self.cache.set(key, value, timeout=self.timeout)
|
|
|
|
def delete(self, key, value):
|
|
if self.get(key) == value:
|
|
self.delete(key)
|
|
|
|
|
|
class PickleCacheMixin:
|
|
def set(self, key, value):
|
|
value, tstamp = value
|
|
value = base64.b64encode(pickle.dumps(value)).decode('ascii')
|
|
super().set(key, (value, tstamp))
|
|
|
|
def get(self, key):
|
|
value = super().get(key)
|
|
if value[0] is not None:
|
|
value, tstamp = value
|
|
try:
|
|
value = base64.b64decode(value.encode('ascii'))
|
|
except ValueError:
|
|
pass
|
|
value = (pickle.loads(value), tstamp)
|
|
return value
|
|
|
|
|
|
class SessionCache(PickleCacheMixin, SimpleDictionnaryCacheMixin, CacheDecoratorBase):
|
|
@property
|
|
def cache(self):
|
|
request = middleware.StoreRequestMiddleware.get_request()
|
|
if not request:
|
|
return {}
|
|
# create a cache dictionary on the request
|
|
return request.session.setdefault(self.__class__.__name__, {})
|
|
|
|
def set(self, key, value):
|
|
request = middleware.StoreRequestMiddleware.get_request()
|
|
if request:
|
|
request.session.modified = True
|
|
return super().set(key, value)
|
|
|
|
def clear(self):
|
|
request = middleware.StoreRequestMiddleware.get_request()
|
|
if request:
|
|
request.session.modified = True
|
|
return super().clear()
|
|
|
|
|
|
@contextmanager
|
|
def errorcollector(error_dict):
|
|
try:
|
|
yield
|
|
except ValidationError as e:
|
|
e.update_error_dict(error_dict)
|
|
|
|
|
|
def json(func):
|
|
'''Convert view to a JSON or JSON web-service supporting CORS'''
|
|
from . import cors
|
|
|
|
@wraps(func)
|
|
def f(request, *args, **kwargs):
|
|
jsonp = False
|
|
# Differentiate JSONP from AJAX
|
|
if request.method == 'GET':
|
|
for variable in ('jsonpCallback', 'callback'):
|
|
if variable in request.GET:
|
|
identifier = request.GET[variable]
|
|
if not re.match(r'^[$a-zA-Z_][0-9a-zA-Z_$]*$', identifier):
|
|
return HttpResponseBadRequest(
|
|
'invalid JSONP callback name', content_type='text/plain'
|
|
)
|
|
jsonp = True
|
|
break
|
|
# 1. check origin
|
|
if jsonp:
|
|
origin = request.META.get('HTTP_REFERER')
|
|
if not origin:
|
|
# JSONP is unusable for people without referers
|
|
return HttpResponseForbidden('missing referrer', content_type='text/plain')
|
|
origin = cors.make_origin(origin)
|
|
else:
|
|
origin = request.META.get('HTTP_ORIGIN')
|
|
if origin:
|
|
if not cors.check_origin(request, origin):
|
|
return HttpResponseForbidden('bad origin', content_type='text/plain')
|
|
# 2. build response
|
|
result = func(request, *args, **kwargs)
|
|
json_str = json_dumps(result)
|
|
if jsonp:
|
|
response = HttpResponse(content_type='application/javascript')
|
|
json_str = '%s(%s);' % (identifier, json_str)
|
|
else:
|
|
response = HttpResponse(content_type='application/json')
|
|
response['Access-Control-Allow-Origin'] = origin
|
|
response['Access-Control-Allow-Credentials'] = 'true'
|
|
response['Access-Control-Allow-Headers'] = 'x-requested-with'
|
|
response.write(json_str)
|
|
return response
|
|
|
|
return f
|