Hi,
tl; dr: Would this project benefit from the ability to sign download URLs (cryptographically and with expiration)?
I thought I would open a discussion on adding download URL signatures.
I recently implemented cryptographic authorization on top of URLs that were generated directly by the storage backend, much like with S3.
These were served with nginx by using X-Accel and a custom view that generated serve requests to the proxy server, offloading the file serving from Django.
The idea is fairly simple and I think many people could benefit from it. Implementation just requires
- a Storage class mixin for specializing URL generation to add signatures in query parameters, and;
- a decorator that validates file download URLs for the download views.
The best thing is that download views will work with or without the signature.
Following a naive example of the idea of the implementation. Please bear in mind that these examples are untested and would, of course, need to be further adapted for django_downloadview
.
# django_downloadview/storage.py
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
class SignedURLMixin(Storage):
""" Mixin for generating signed file URLs with storage backends. Adds X-Signature query parameter to the normal URLs generated by the storage backend."""
def url(self, name):
signer = TimestampSigner()
expiration = getattr(settings, "DOWNLOADVIEW_URL_EXPIRATION", None)
path = super(SignedURLMixin, self).url(name)
signature = signer.sign(path)
return '{}?X-Signature={}'.format(path, signature)
class SignedFileSystemStorage(SignedURLMixin, FileSystemStorage):
pass
# django_downloadview/decorators.py
from functools import wraps
from django.core.exceptions import PermissionDenied
def signature_required(function):
""" Decorator that checks for X-Signature query parameter to authorize specific user access. """
@wraps
def decorator(request, *args, **kwargs):
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")
return function(request, *args, **kwargs)
return decorator
Then the usage can simply be:
# demoproject/urls.py
# Django is set up with
# DEFAULT_FILE_STORAGE='example.storage.SignedFileSystemStorage'
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')
url_patterns = ('',
url('^download/(?P<slug>[A-Za-z0-9_-]+)/$', signature_required(download), name='download'),
)
{# demoproject/download/template.html #}
{# URLs in templates are generated with the storage class URL implementation #}
<a href="{{ object.file.url }}">Click here to download.</a>
The S3 Boto storage backend uses a similar approach and makes it possible to generate URLs in user templates and then authorize S3 access with those URLs. This vanilla Django approach makes it very easy to emulate that behaviour.
Additional hardening can then be achieved with:
- Adding random salts to signing, and expiration times to the TimestampSigner
- Only ever using signed download links generated with the storage backend using
{{ file.url }}
This approach only lacks in that it introduces non-cacheable URLs that require slight computation to decrypt.
Inspiration was received from Grok. You can find more information on generic URL signatures in his weblog:
- http://grokcode.com/819/one-click-unsubscribes-for-django-apps/
If signatures are appended to URLs with existing query parameters, a more sophisticated solution has to be used. For example:
- https://stackoverflow.com/questions/2506379/add-params-to-given-url-in-python