diff --git a/AUTHORS b/AUTHORS index d45879e..41faa7c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,5 +11,6 @@ Original code by `Novapost `_ team: * Gregory Tappero * Rémy Hubscher * Benoît Bryon +* Aleksi Häkli Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors diff --git a/INSTALL b/INSTALL index 780e285..f16bf8e 100644 --- a/INSTALL +++ b/INSTALL @@ -12,7 +12,7 @@ Install Requirements ************ -`django-downloadview` has been tested with `Python`_ 2.7, 3.3 and 3.4. Other +`django-downloadview` has been tested with `Python`_ 3.6, 3.7 and 3.8. Other versions may work, but they are not part of the test suite at the moment. Installing `django-downloadview` will automatically trigger the installation of diff --git a/django_downloadview/decorators.py b/django_downloadview/decorators.py index bac77d0..7f014da 100644 --- a/django_downloadview/decorators.py +++ b/django_downloadview/decorators.py @@ -5,6 +5,12 @@ See also decorators provided by server-specific modules, such as """ +from functools import wraps + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.signing import BadSignature, SignatureExpired, TimestampSigner + class DownloadDecorator(object): """View decorator factory to apply middleware to ``view_func``'s response. @@ -32,3 +38,39 @@ class DownloadDecorator(object): return middleware.process_response(request, response) return decorated + + +def _signature_is_valid(request): + """ + Validator that raises a PermissionDenied error on invalid and + mismatching signatures. + """ + + signer = TimestampSigner() + signature = request.GET.get("X-Signature") + expiration = getattr(settings, "DOWNLOADVIEW_URL_EXPIRATION", None) + + try: + signature_path = signer.unsign(signature, max_age=expiration) + except SignatureExpired as e: + raise PermissionDenied("Signature expired") from e + except BadSignature as e: + raise PermissionDenied("Signature invalid") from e + except Exception as e: + raise PermissionDenied("Signature error") from e + + if request.path != signature_path: + raise PermissionDenied("Signature mismatch") + + +def signature_required(function): + """ + Decorator that checks for X-Signature query parameter to authorize access to views. + """ + + @wraps(function) + def decorator(request, *args, **kwargs): + _signature_is_valid() + return function(request, *args, **kwargs) + + return decorator diff --git a/django_downloadview/storage.py b/django_downloadview/storage.py new file mode 100644 index 0000000..691411d --- /dev/null +++ b/django_downloadview/storage.py @@ -0,0 +1,22 @@ +from django.core.files.storage import FileSystemStorage +from django.core.signing import TimestampSigner + + +class SignedURLMixin: + """ + Mixin for generating signed file URLs with compatible storage backends. + + Adds X-Signature query parameters to the normal URLs generated by the storage class. + """ + + def url(self, name): + path = super(SignedURLMixin, self).url(name) + signer = TimestampSigner() + signature = signer.sign(path) + return "{}?X-Signature={}".format(path, signature) + + +class SignedFileSystemStorage(SignedURLMixin, FileSystemStorage): + """ + Specialized filesystem storage that signs file URLs for clients. + """ diff --git a/docs/settings.txt b/docs/settings.txt index 3e197a0..dc5b996 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -29,6 +29,48 @@ Example: :end-before: END middlewares +******************** +DEFAULT_FILE_STORAGE +******************** + +django-downloadview offers a built-in signed file storage, which cryptographically +signs requested file URLs with the Django's built-in TimeStampSigner. + +To utilize the signed storage views you can configure + +.. code:: python + + DEFAULT_FILE_STORAGE='django_downloadview.storage.SignedStorage' + +The signed file storage system inserts a ``X-Signature`` header to the requested file +URLs, and they can then be verified with the supplied ``signature_required`` wrapper function: + +.. code:: python + + from django.conf.urls import url, url_patterns + + from django_downloadview import ObjectDownloadView + from django_downloadview.decorators import signature_required + + from demoproject.download.models import Document # A model with a FileField + + # ObjectDownloadView inherits from django.views.generic.BaseDetailView. + download = ObjectDownloadView.as_view(model=Document, file_field='file') + + urlpatterns = [ + path('download//', signature_required(download), + ] + +Make sure to test the desired functionality after configuration. + +*************************** +DOWNLOADVIEW_URL_EXPIRATION +*************************** + +Number of seconds signed download URLs are valid before expiring. + +Default value for this flag is None and URLs never expire. + ******************** DOWNLOADVIEW_BACKEND ******************** @@ -78,4 +120,4 @@ See :doc:`/optimizations/index` for details about builtin backends When ``django_downloadview.SmartDownloadMiddleware`` is in your ``MIDDLEWARE_CLASSES``, this setting must be explicitely configured (no default -value). Else, you can ignore this setting. +value). Else, you can ignore this setting. \ No newline at end of file diff --git a/setup.py b/setup.py index 9c9aa10..54270ea 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ class Tox(TestCommand): def run_tests(self): import tox # import here, cause outside the eggs aren't loaded. + errno = tox.cmdline(self.test_args) sys.exit(errno) diff --git a/tests/signature.py b/tests/signature.py new file mode 100644 index 0000000..e485805 --- /dev/null +++ b/tests/signature.py @@ -0,0 +1,61 @@ +""" +Test signature generation and validation. +""" + +import unittest + +from django.core.exceptions import PermissionDenied +from django.core.signing import TimestampSigner + +from django_downloadview.decorators import _signature_is_valid +from django_downloadview.storage import SignedURLMixin + + +class TestStorage: + def url(self, name): + return "https://example.com/{name}".format(name=name) + + +class SignedTestStorage(SignedURLMixin, TestStorage): + pass + + +class SignatureGeneratorTestCase(unittest.TestCase): + def test_signed_storage(self): + """ + django_downloadview.storage.SignedURLMixin adds X-Signature to URLs. + """ + + storage = SignedTestStorage() + url = storage.url("test") + self.assertIn("https://example.com/test?X-Signature=", url) + + +class SignatureValidatorTestCase(unittest.TestCase): + def test_verify_signature(self): + """ + django_downloadview.decorators._signature_is_valid returns True on + valid signatures. + """ + + signer = TimestampSigner() + request = unittest.mock.MagicMock() + + request.path = "test" + request.GET = {"X-Signature": signer.sign("test")} + + self.assertIsNone(_signature_is_valid(request)) + + def test_verify_signature_invalid(self): + """ + django_downloadview.decorators._signature_is_valid raises PermissionDenied + on invalid signatures. + """ + + request = unittest.mock.MagicMock() + + request.path = "test" + request.GET = {"X-Signature": "not-valid"} + + with self.assertRaises(PermissionDenied): + _signature_is_valid(request)