mirror of
https://github.com/jazzband/django-downloadview.git
synced 2026-03-16 22:40:25 +00:00
Merge pull request #151 from aleksihakli/master
Add signed file system storage
This commit is contained in:
commit
ee402dbcb8
7 changed files with 171 additions and 2 deletions
1
AUTHORS
1
AUTHORS
|
|
@ -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
|
||||
|
|
|
|||
2
INSTALL
2
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
django_downloadview/storage.py
Normal file
22
django_downloadview/storage.py
Normal 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.
|
||||
"""
|
||||
|
|
@ -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.
|
||||
1
setup.py
1
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)
|
||||
|
||||
|
|
|
|||
61
tests/signature.py
Normal file
61
tests/signature.py
Normal 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)
|
||||
Loading…
Reference in a new issue