jazzband / django-downloadview

Serve files with Django.

Home Page:https://django-downloadview.readthedocs.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Implement signatures for file download URLs

aleksihakli opened this issue · comments

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:

If signatures are appended to URLs with existing query parameters, a more sophisticated solution has to be used. For example:

Hi @benoitbryon and @Natim, would this be something that is needed in this project? I could implement this in a PR but would like to know if there has been any previous discussion or opinions on the subject.

@aleksihakli feel free to do so if you think it is still interesting yes.

I'll try and find time for making a PR. The example code should work as-is. I've tested it locally in the past. It could be added to the codebase with a few unit tests.

@Natim the docs seem to be of the old version by the way, is it possible to update the RTD site with the new contents which also describe using the new configuration options?

Yes I asked @benoitbryon about that. But we might consider asking someone from RTD to give us access.

Hi there :)
@Natim, I just granted you maintainer of django-downloadview on RTD. Is it ok for you?
Feel free to make the changes you think are necessary.