Merge pull request #151 from aleksihakli/master

Add signed file system storage
This commit is contained in:
Rémy HUBSCHER 2020-01-13 10:49:12 +01:00 committed by GitHub
commit ee402dbcb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 171 additions and 2 deletions

View file

@ -11,5 +11,6 @@ Original code by `Novapost <http://www.novapost.fr>`_ team:
* Gregory Tappero <https://github.com/coulix>
* Rémy Hubscher <remy.hubscher@novapost.fr>
* Benoît Bryon <benoit@marmelune.net>
* Aleksi Häkli <https://github.com/aleksihakli>
Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors

View file

@ -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

View file

@ -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

View file

@ -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.
"""

View file

@ -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/<str:slug>/', 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.

View file

@ -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)

61
tests/signature.py Normal file
View file

@ -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)